Реализованы сервисы ВМ2 - проверка сообщений и синхронизация с Б24 (деплой еще без перевода в боевой режим)
This commit is contained in:
@@ -6,6 +6,7 @@ import socket
|
||||
import time
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
from urllib.parse import urlparse
|
||||
|
||||
@@ -25,9 +26,19 @@ return {current, ttl}
|
||||
|
||||
|
||||
class DependencyFailure(Exception):
|
||||
def __init__(self, code: str = "dependency_unavailable", timeout: bool = False) -> None:
|
||||
def __init__(
|
||||
self,
|
||||
code: str = "dependency_unavailable",
|
||||
timeout: bool = False,
|
||||
*,
|
||||
terminal: bool = False,
|
||||
retryable: bool = True,
|
||||
) -> None:
|
||||
super().__init__(code)
|
||||
self.code = code
|
||||
self.timeout = timeout
|
||||
self.terminal = terminal
|
||||
self.retryable = retryable
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
@@ -106,20 +117,37 @@ class SafetyClient:
|
||||
async def check(self, payload: dict[str, Any], request_id: str) -> dict[str, Any]:
|
||||
return await self._call(
|
||||
"POST",
|
||||
"/internal/safety/v1/messages/check",
|
||||
f"{self.settings.message_safety_api_prefix}/messages/check",
|
||||
request_id,
|
||||
json=payload,
|
||||
timeout=self.settings.message_safety_post_timeout_sec,
|
||||
)
|
||||
|
||||
async def poll(self, task_id: str, request_id: str) -> dict[str, Any]:
|
||||
async def poll(self, location: str, request_id: str) -> dict[str, Any]:
|
||||
path = self._poll_path(location)
|
||||
return await self._call(
|
||||
"GET",
|
||||
f"/internal/safety/v1/messages/tasks/{task_id}",
|
||||
path,
|
||||
request_id,
|
||||
timeout=2,
|
||||
)
|
||||
|
||||
def _poll_path(self, location: str) -> str:
|
||||
expected_prefix = f"{self.settings.message_safety_api_prefix}/messages/tasks/"
|
||||
parsed = urlparse(location)
|
||||
if parsed.scheme or parsed.netloc or parsed.query or parsed.fragment:
|
||||
raise DependencyFailure("invalid_safety_location", terminal=True, retryable=False)
|
||||
if not parsed.path.startswith(expected_prefix):
|
||||
raise DependencyFailure("invalid_safety_location", terminal=True, retryable=False)
|
||||
task_id = parsed.path.removeprefix(expected_prefix)
|
||||
try:
|
||||
uuid.UUID(task_id)
|
||||
except ValueError as exc:
|
||||
raise DependencyFailure(
|
||||
"invalid_safety_location", terminal=True, retryable=False
|
||||
) from exc
|
||||
return parsed.path
|
||||
|
||||
async def _call(self, method: str, path: str, request_id: str, **kwargs: Any) -> dict[str, Any]:
|
||||
if not self.breaker.allow():
|
||||
raise DependencyFailure()
|
||||
@@ -140,9 +168,31 @@ class SafetyClient:
|
||||
except httpx.HTTPError as exc:
|
||||
self.breaker.failure()
|
||||
raise DependencyFailure() from exc
|
||||
if response.status_code == 401 or response.status_code >= 500:
|
||||
if response.status_code >= 500:
|
||||
self.breaker.failure()
|
||||
raise DependencyFailure()
|
||||
code, terminal, retryable = "dependency_unavailable", False, True
|
||||
try:
|
||||
details = response.json().get("error", {}).get("details", {})
|
||||
code = response.json().get("error", {}).get("code", code)
|
||||
terminal = details.get("terminal") is True
|
||||
retryable = details.get("retryable") is not False
|
||||
except (AttributeError, ValueError):
|
||||
pass
|
||||
raise DependencyFailure(code, terminal=terminal, retryable=retryable)
|
||||
if response.status_code not in (200, 202, 403):
|
||||
if response.status_code == 401:
|
||||
self.breaker.failure()
|
||||
code = "safety_request_rejected"
|
||||
try:
|
||||
code = response.json().get("error", {}).get("code", code)
|
||||
except (AttributeError, ValueError):
|
||||
pass
|
||||
retryable = response.status_code in (404, 429)
|
||||
raise DependencyFailure(
|
||||
code,
|
||||
terminal=not retryable,
|
||||
retryable=retryable,
|
||||
)
|
||||
try:
|
||||
body = response.json()
|
||||
except ValueError as exc:
|
||||
@@ -151,10 +201,66 @@ class SafetyClient:
|
||||
if not isinstance(body, dict):
|
||||
self.breaker.failure()
|
||||
raise DependencyFailure()
|
||||
status = response.status_code
|
||||
expected_verdict = {200: "allow", 202: "pending", 403: "deny"}[status]
|
||||
if (
|
||||
body.get("verdict") != expected_verdict
|
||||
or body.get("processing_mode") not in ("standard", "mock")
|
||||
or type(body.get("config_version")) is not int
|
||||
or not body.get("rules_version")
|
||||
):
|
||||
self.breaker.failure()
|
||||
raise DependencyFailure("invalid_safety_response")
|
||||
if status == 202:
|
||||
location = response.headers.get("Location")
|
||||
retry_after = response.headers.get("Retry-After")
|
||||
if (
|
||||
body["processing_mode"] != "standard"
|
||||
or not location
|
||||
or not retry_after
|
||||
or not body.get("task_id")
|
||||
or not body.get("expires_at")
|
||||
or type(body.get("poll_after_ms")) is not int
|
||||
):
|
||||
self.breaker.failure()
|
||||
raise DependencyFailure("invalid_safety_response")
|
||||
try:
|
||||
location_task_id = self._poll_path(location).rsplit("/", 1)[-1]
|
||||
except DependencyFailure as exc:
|
||||
self.breaker.failure()
|
||||
raise DependencyFailure("invalid_safety_response") from exc
|
||||
if body["task_id"] != location_task_id:
|
||||
self.breaker.failure()
|
||||
raise DependencyFailure("invalid_safety_response")
|
||||
try:
|
||||
if int(retry_after) <= 0 or body["poll_after_ms"] <= 0:
|
||||
raise ValueError
|
||||
datetime_value = body["expires_at"].replace("Z", "+00:00")
|
||||
datetime.fromisoformat(datetime_value)
|
||||
except (AttributeError, TypeError, ValueError) as exc:
|
||||
self.breaker.failure()
|
||||
raise DependencyFailure("invalid_safety_response") from exc
|
||||
body["_location"] = location
|
||||
body["_retry_after"] = retry_after
|
||||
elif not body.get("rule_id") or (
|
||||
status == 403 and body.get("reason_code") != "message_blocked"
|
||||
):
|
||||
self.breaker.failure()
|
||||
raise DependencyFailure("invalid_safety_response")
|
||||
self.breaker.success()
|
||||
body["_status"] = response.status_code
|
||||
return body
|
||||
|
||||
async def ready(self) -> bool:
|
||||
try:
|
||||
response = await self.http.get(
|
||||
f"{str(self.settings.message_safety_url).rstrip('/')}/health/ready",
|
||||
timeout=2,
|
||||
)
|
||||
return response.status_code == 200
|
||||
except httpx.HTTPError:
|
||||
return False
|
||||
|
||||
|
||||
class OpenLinesClient:
|
||||
def __init__(self, settings: Settings, http: httpx.AsyncClient) -> None:
|
||||
@@ -272,7 +378,14 @@ class S3Client:
|
||||
async def head(self, bucket: str, key: str) -> dict[str, Any]:
|
||||
return await asyncio.to_thread(self.client.head_object, Bucket=bucket, Key=key)
|
||||
|
||||
async def promote(self, source_key: str, destination_key: str) -> None:
|
||||
async def promote(
|
||||
self,
|
||||
source_key: str,
|
||||
destination_key: str,
|
||||
*,
|
||||
version_id: str,
|
||||
etag: str,
|
||||
) -> None:
|
||||
await asyncio.to_thread(
|
||||
self.client.copy_object,
|
||||
Bucket=self.settings.selectel_s3_bucket_attachments,
|
||||
@@ -280,13 +393,13 @@ class S3Client:
|
||||
CopySource={
|
||||
"Bucket": self.settings.selectel_s3_bucket_quarantine,
|
||||
"Key": source_key,
|
||||
"VersionId": version_id,
|
||||
},
|
||||
CopySourceIfMatch=etag,
|
||||
)
|
||||
await asyncio.to_thread(
|
||||
self.client.delete_object,
|
||||
Bucket=self.settings.selectel_s3_bucket_quarantine,
|
||||
Key=source_key,
|
||||
)
|
||||
# Keep the immutable source version until quarantine lifecycle expiry.
|
||||
# A crash after copy but before the DB checkpoint can then safely retry
|
||||
# the same conditional copy without losing its source.
|
||||
|
||||
async def delete_quarantine(self, key: str) -> None:
|
||||
await asyncio.to_thread(
|
||||
|
||||
Reference in New Issue
Block a user