Files
han-app/VM2_services/codebase/services/bitrix-sync/app/crm.py
T

212 lines
7.5 KiB
Python

from __future__ import annotations
import asyncio
import ssl
from dataclasses import dataclass
from datetime import UTC, datetime
from enum import StrEnum
from time import monotonic
from typing import Any
from urllib.parse import urljoin, urlsplit
import httpx
from opentelemetry.trace import SpanKind
from app.telemetry import record_crm, record_retry, safe_span
class CrmOutcome(StrEnum):
SUCCEEDED = "succeeded"
RETRY = "retry"
UNCERTAIN = "uncertain"
PERMANENT = "permanent"
RATE_LIMITED = "rate_limited"
@dataclass(frozen=True)
class CrmResult:
outcome: CrmOutcome
result: Any = None
error_code: str | None = None
http_status: int | None = None
retry_after: float | None = None
class CrmClient:
"""Host-locked, verified-TLS Bitrix client; redirects are never followed."""
def __init__(self, credential_url: str, approved_host: str, timeout: float) -> None:
parsed = urlsplit(credential_url)
if parsed.scheme != "https" or parsed.hostname != approved_host:
raise ValueError("credential URL is outside approved Bitrix host")
self._base_url = credential_url.rstrip("/") + "/"
self._host = approved_host
self._client = httpx.AsyncClient(
timeout=httpx.Timeout(timeout),
verify=ssl.create_default_context(),
follow_redirects=False,
limits=httpx.Limits(max_connections=4, max_keepalive_connections=2),
headers={"Accept": "application/json"},
)
async def close(self) -> None:
await self._client.aclose()
async def call(self, method: str, params: dict[str, Any], *, mutating: bool) -> CrmResult:
started = monotonic()
outcome = "error"
operation = method.rsplit(".", 1)[-1]
try:
with safe_span(
"bitrix_sync.crm.request",
kind=SpanKind.CLIENT,
attributes={"crm.operation": operation},
):
result = await self._call(method, params, mutating=mutating)
outcome = result.outcome.value
if result.outcome in {
CrmOutcome.RETRY,
CrmOutcome.UNCERTAIN,
CrmOutcome.RATE_LIMITED,
}:
record_retry("crm")
return result
finally:
record_crm(method, outcome, monotonic() - started)
async def _call(self, method: str, params: dict[str, Any], *, mutating: bool) -> CrmResult:
if method not in ALLOWED_METHODS:
raise ValueError("unapproved CRM method")
url = urljoin(self._base_url, method + ".json")
if urlsplit(url).hostname != self._host:
raise ValueError("CRM host changed during URL construction")
try:
response = await self._client.post(url, json=params)
except (httpx.ConnectError, httpx.ReadError, httpx.RemoteProtocolError):
return CrmResult(CrmOutcome.RETRY, error_code="crm_network")
except httpx.TimeoutException:
outcome = CrmOutcome.UNCERTAIN if mutating else CrmOutcome.RETRY
return CrmResult(outcome, error_code="crm_timeout")
if response.is_redirect:
return CrmResult(
CrmOutcome.PERMANENT,
error_code="crm_redirect_rejected",
http_status=response.status_code,
)
retry_after = _retry_after(response)
if response.status_code in (408, 429) or response.status_code >= 500:
return CrmResult(
CrmOutcome.RETRY,
error_code=f"crm_http_{response.status_code}",
http_status=response.status_code,
retry_after=retry_after,
)
try:
payload = response.json()
except ValueError:
return CrmResult(
CrmOutcome.PERMANENT,
error_code="crm_malformed_response",
http_status=response.status_code,
)
error = payload.get("error")
if error == "QUERY_LIMIT_EXCEEDED":
return CrmResult(CrmOutcome.RATE_LIMITED, error_code=error, retry_after=retry_after)
if error == "OPERATION_TIME_LIMIT":
return CrmResult(CrmOutcome.RETRY, error_code=error, retry_after=retry_after)
if error:
permanent = error in {
"ERROR_METHOD_NOT_FOUND",
"ERROR_WRONG_AUTH_TYPE",
"INVALID_CREDENTIALS",
"ACCESS_DENIED",
"ERROR_ARGUMENT",
}
return CrmResult(
CrmOutcome.PERMANENT if permanent else CrmOutcome.RETRY,
error_code=str(error)[:64],
http_status=response.status_code,
)
return CrmResult(CrmOutcome.SUCCEEDED, result=payload.get("result"))
async def batch(self, commands: list[tuple[str, dict[str, Any]]]) -> list[CrmResult]:
if not commands:
return []
if len(commands) > 50:
raise ValueError("Bitrix batch limit exceeded")
cmd = {
str(index): f"{method}?{httpx.QueryParams(params)}"
for index, (method, params) in enumerate(commands)
if method in ALLOWED_METHODS
}
batch = await self.call("batch", {"halt": 0, "cmd": cmd}, mutating=True)
if batch.outcome != CrmOutcome.SUCCEEDED:
return [batch for _ in commands]
result = batch.result or {}
successes = result.get("result", {})
errors = result.get("result_error", {})
return [
CrmResult(CrmOutcome.SUCCEEDED, result=successes.get(str(i)))
if str(i) in successes
else CrmResult(CrmOutcome.RETRY, error_code=str(errors.get(str(i), "batch_missing")))
for i in range(len(commands))
]
ALLOWED_METHODS = frozenset(
{
"batch",
"crm.duplicate.findbycomm",
"crm.contact.get",
"crm.contact.add",
"crm.contact.update",
"crm.contact.userfield.list",
"crm.item.list",
"crm.item.get",
"crm.item.add",
"crm.item.update",
}
)
def _retry_after(response: httpx.Response) -> float | None:
value = response.headers.get("Retry-After")
if not value:
return None
try:
return max(0.0, float(value))
except ValueError:
try:
retry_at = datetime.fromisoformat(value).astimezone(UTC)
return max(0.0, (retry_at - datetime.now(UTC)).total_seconds())
except ValueError:
return None
class TokenBucket:
def __init__(self, refill_per_second: float, burst: int, max_in_flight: int) -> None:
self.refill_per_second = refill_per_second
self.burst = float(burst)
self.tokens = float(burst)
self.updated_at = asyncio.get_running_loop().time()
self._lock = asyncio.Lock()
self._slots = asyncio.Semaphore(max_in_flight)
async def acquire(self) -> None:
await self._slots.acquire()
while True:
async with self._lock:
now = asyncio.get_running_loop().time()
self.tokens = min(
self.burst, self.tokens + (now - self.updated_at) * self.refill_per_second
)
self.updated_at = now
if self.tokens >= 1:
self.tokens -= 1
return
delay = (1 - self.tokens) / self.refill_per_second
await asyncio.sleep(delay)
def release(self) -> None:
self._slots.release()