Files
han-app/codebase/backend/api-backend/app/integrations.py
T

344 lines
12 KiB
Python

import asyncio
import hashlib
import ipaddress
import json
import socket
import time
import uuid
from dataclasses import dataclass
from typing import Any
from urllib.parse import urlparse
import boto3
import httpx
from botocore.config import Config
from redis.asyncio import Redis
from app.settings import Settings
RATE_LIMIT_LUA = """
local current = redis.call('INCR', KEYS[1])
if current == 1 then redis.call('EXPIRE', KEYS[1], ARGV[1]) end
local ttl = redis.call('TTL', KEYS[1])
return {current, ttl}
"""
class DependencyFailure(Exception):
def __init__(self, code: str = "dependency_unavailable", timeout: bool = False) -> None:
self.code = code
self.timeout = timeout
@dataclass(slots=True)
class CircuitBreaker:
threshold: int
open_seconds: float
failures: int = 0
opened_at: float | None = None
def allow(self) -> bool:
if self.opened_at is None:
return True
if time.monotonic() - self.opened_at >= self.open_seconds:
self.opened_at = None
self.failures = max(0, self.threshold - 1)
return True
return False
def success(self) -> None:
self.failures = 0
self.opened_at = None
def failure(self) -> None:
self.failures += 1
if self.failures >= self.threshold:
self.opened_at = time.monotonic()
class RateLimiter:
def __init__(self, redis: Redis) -> None:
self.redis = redis
async def consume(self, key: str, limit: int, window: int) -> int:
try:
count, ttl = await self.redis.eval(RATE_LIMIT_LUA, 1, key, window)
except Exception as exc:
raise DependencyFailure() from exc
if int(count) > limit:
return max(1, int(ttl))
return 0
@staticmethod
def key(identity_type: str, identity: str, route: str, window: int) -> str:
safe_identity = hashlib.sha256(identity.encode()).hexdigest()[:32]
bucket = int(time.time()) // window
return f"han:api:rl:{identity_type}:{safe_identity}:{route}:{bucket}"
class RedisIdempotency:
def __init__(self, redis: Redis) -> None:
self.redis = redis
async def get(self, scope: str, user_id: uuid.UUID, key: str) -> dict[str, Any] | None:
key_hash = hashlib.sha256(key.encode()).hexdigest()
raw = await self.redis.get(f"han:api:idem:{scope}:{user_id}:{key_hash}")
return json.loads(raw) if raw else None
async def put(self, scope: str, user_id: uuid.UUID, key: str, value: dict[str, Any]) -> None:
key_hash = hashlib.sha256(key.encode()).hexdigest()
await self.redis.set(
f"han:api:idem:{scope}:{user_id}:{key_hash}",
json.dumps(value, separators=(",", ":"), default=str),
ex=86400,
)
class SafetyClient:
def __init__(self, settings: Settings, http: httpx.AsyncClient) -> None:
self.settings = settings
self.http = http
self.breaker = CircuitBreaker(
settings.message_safety_circuit_failure_threshold,
settings.message_safety_circuit_open_sec,
)
async def check(self, payload: dict[str, Any], request_id: str) -> dict[str, Any]:
return await self._call(
"POST",
"/internal/safety/v1/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]:
return await self._call(
"GET",
f"/internal/safety/v1/messages/tasks/{task_id}",
request_id,
timeout=2,
)
async def _call(self, method: str, path: str, request_id: str, **kwargs: Any) -> dict[str, Any]:
if not self.breaker.allow():
raise DependencyFailure()
headers = {
"X-Service-Token": self.settings.message_safety_service_token.get_secret_value(),
"X-Request-ID": request_id,
}
try:
response = await self.http.request(
method,
f"{str(self.settings.message_safety_url).rstrip('/')}{path}",
headers=headers,
**kwargs,
)
except httpx.TimeoutException as exc:
self.breaker.failure()
raise DependencyFailure(timeout=True) from exc
except httpx.HTTPError as exc:
self.breaker.failure()
raise DependencyFailure() from exc
if response.status_code == 401 or response.status_code >= 500:
self.breaker.failure()
raise DependencyFailure()
try:
body = response.json()
except ValueError as exc:
self.breaker.failure()
raise DependencyFailure() from exc
if not isinstance(body, dict):
self.breaker.failure()
raise DependencyFailure()
self.breaker.success()
body["_status"] = response.status_code
return body
class OpenLinesClient:
def __init__(self, settings: Settings, http: httpx.AsyncClient) -> None:
self.settings = settings
self.http = http
self.breaker = CircuitBreaker(
settings.bitrix_local_app_circuit_failure_threshold,
settings.bitrix_local_app_circuit_open_sec,
)
async def send(
self, message_id: uuid.UUID, payload: dict[str, Any], request_id: str
) -> dict[str, Any]:
if not self.breaker.allow():
raise DependencyFailure()
try:
response = await self.http.post(
f"{str(self.settings.bitrix_local_app_base_url).rstrip('/')}"
"/internal/openlines/v1/messages",
json=payload,
headers={
"Authorization": "Bearer "
+ self.settings.bitrix_local_app_internal_token.get_secret_value(),
"Idempotency-Key": str(message_id),
"X-Request-ID": request_id,
},
timeout=self.settings.bitrix_local_app_http_timeout_sec,
)
response.raise_for_status()
except httpx.TimeoutException as exc:
self.breaker.failure()
raise DependencyFailure(timeout=True) from exc
except httpx.HTTPError as exc:
self.breaker.failure()
raise DependencyFailure() from exc
self.breaker.success()
return response.json()
async def ready(self) -> bool:
try:
response = await self.http.get(
f"{str(self.settings.bitrix_local_app_base_url).rstrip('/')}"
"/internal/openlines/v1/status",
headers={
"Authorization": "Bearer "
+ self.settings.bitrix_local_app_internal_token.get_secret_value()
},
timeout=2,
)
return response.is_success
except httpx.HTTPError:
return False
async def fresh_openlines_payload(
payload: dict[str, Any], s3: "S3Client"
) -> dict[str, Any]:
result = json.loads(json.dumps(payload, default=str))
for file in result.get("message", {}).get("files", []):
bucket = file.pop("_storage_bucket")
key = file.pop("_object_key")
file["download_url"] = await s3.presign_get(bucket, key)
return result
class S3Client:
def __init__(self, settings: Settings) -> None:
self.settings = settings
self.client = boto3.client(
"s3",
endpoint_url=str(settings.selectel_s3_endpoint_url),
aws_access_key_id=settings.selectel_s3_access_key.get_secret_value(),
aws_secret_access_key=settings.selectel_s3_secret_key.get_secret_value(),
config=Config(
connect_timeout=3,
read_timeout=10,
retries={"max_attempts": 2},
s3={"addressing_style": "virtual"},
),
)
async def ready(self) -> bool:
try:
for bucket in (
self.settings.selectel_s3_bucket_quarantine,
self.settings.selectel_s3_bucket_attachments,
self.settings.selectel_s3_bucket_documents,
):
await asyncio.to_thread(self.client.head_bucket, Bucket=bucket)
return True
except Exception:
return False
async def presign_put(self, key: str, mime: str, ttl: int) -> str:
return await asyncio.to_thread(
self.client.generate_presigned_url,
"put_object",
Params={
"Bucket": self.settings.selectel_s3_bucket_quarantine,
"Key": key,
"ContentType": mime,
},
ExpiresIn=ttl,
)
async def presign_get(self, bucket: str, key: str, ttl: int = 300) -> str:
return await asyncio.to_thread(
self.client.generate_presigned_url,
"get_object",
Params={"Bucket": bucket, "Key": key},
ExpiresIn=ttl,
)
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:
await asyncio.to_thread(
self.client.copy_object,
Bucket=self.settings.selectel_s3_bucket_attachments,
Key=destination_key,
CopySource={
"Bucket": self.settings.selectel_s3_bucket_quarantine,
"Key": source_key,
},
)
await asyncio.to_thread(
self.client.delete_object,
Bucket=self.settings.selectel_s3_bucket_quarantine,
Key=source_key,
)
async def delete_quarantine(self, key: str) -> None:
await asyncio.to_thread(
self.client.delete_object,
Bucket=self.settings.selectel_s3_bucket_quarantine,
Key=key,
)
async def upload_inbound(
self,
http: httpx.AsyncClient,
download_url: str,
destination_key: str,
mime_type: str,
max_bytes: int,
) -> tuple[int, str]:
parsed = urlparse(download_url)
if parsed.scheme != "https" or not parsed.hostname:
raise DependencyFailure("unsafe_inbound_url")
try:
addresses = await asyncio.to_thread(
socket.getaddrinfo, parsed.hostname, parsed.port or 443, type=socket.SOCK_STREAM
)
except OSError as exc:
raise DependencyFailure("unsafe_inbound_url") from exc
if any(
ipaddress.ip_address(address[4][0]).is_private
or ipaddress.ip_address(address[4][0]).is_loopback
or ipaddress.ip_address(address[4][0]).is_link_local
or ipaddress.ip_address(address[4][0]).is_reserved
for address in addresses
):
raise DependencyFailure("unsafe_inbound_url")
data = bytearray()
try:
async with http.stream(
"GET", download_url, timeout=10, follow_redirects=False
) as response:
response.raise_for_status()
if response.headers.get("content-type", "").split(";")[0] != mime_type:
raise DependencyFailure("inbound_mime_mismatch")
async for chunk in response.aiter_bytes():
data.extend(chunk)
if len(data) > max_bytes:
raise DependencyFailure("inbound_file_too_large")
except httpx.HTTPError as exc:
raise DependencyFailure() from exc
await asyncio.to_thread(
self.client.put_object,
Bucket=self.settings.selectel_s3_bucket_attachments,
Key=destination_key,
Body=bytes(data),
ContentType=mime_type,
)
return len(data), hashlib.sha256(data).hexdigest()