Реализованы сервисы ВМ2 - проверка сообщений и синхронизация с Б24 (деплой еще без перевода в боевой режим)

This commit is contained in:
mi
2026-08-13 18:52:42 +03:00
parent 5100ba9fc3
commit 99605b1c77
144 changed files with 15295 additions and 1120 deletions
+125 -12
View File
@@ -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(