Restructure with Turborepo
This commit is contained in:
@@ -0,0 +1,17 @@
|
||||
from .base_connector import (
|
||||
BaseInventoryConnector,
|
||||
ConnectorResult,
|
||||
StockInfo,
|
||||
PharmacyInfo,
|
||||
SyncResult,
|
||||
ReservationResult,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"BaseInventoryConnector",
|
||||
"ConnectorResult",
|
||||
"StockInfo",
|
||||
"PharmacyInfo",
|
||||
"SyncResult",
|
||||
"ReservationResult",
|
||||
]
|
||||
@@ -0,0 +1,78 @@
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class ConnectorResult(BaseModel):
|
||||
success: bool
|
||||
data: Any | None = None
|
||||
error: str | None = None
|
||||
metadata: dict | None = None
|
||||
|
||||
|
||||
class StockInfo(BaseModel):
|
||||
medication_id: UUID
|
||||
nregistro: str
|
||||
name: str
|
||||
stock: int
|
||||
reserved_stock: int = 0
|
||||
available_stock: int = 0
|
||||
price: float | None = None
|
||||
last_updated: str | None = None
|
||||
|
||||
|
||||
class PharmacyInfo(BaseModel):
|
||||
pharmacy_id: UUID
|
||||
code: str
|
||||
name: str
|
||||
address: str | None = None
|
||||
phone: str | None = None
|
||||
email: str | None = None
|
||||
opening_hours: dict | None = None
|
||||
latitude: float | None = None
|
||||
longitude: float | None = None
|
||||
|
||||
|
||||
class SyncResult(BaseModel):
|
||||
success: bool
|
||||
items_total: int = 0
|
||||
items_processed: int = 0
|
||||
items_failed: int = 0
|
||||
error_message: str | None = None
|
||||
metadata: dict | None = None
|
||||
|
||||
|
||||
class ReservationResult(BaseModel):
|
||||
success: bool
|
||||
reservation_id: str | None = None
|
||||
error: str | None = None
|
||||
|
||||
|
||||
class BaseInventoryConnector(ABC):
|
||||
connector_type: str = "base"
|
||||
|
||||
@abstractmethod
|
||||
async def get_stock(self, pharmacy_id: UUID, medication_id: UUID | None = None, nregistro: str | None = None) -> ConnectorResult:
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
async def reserve(self, pharmacy_id: UUID, medication_id: UUID, quantity: int, idempotency_key: str | None = None) -> ConnectorResult:
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
async def cancel_reservation(self, pharmacy_id: UUID, reservation_id: str) -> ConnectorResult:
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
async def pharmacy_information(self, pharmacy_id: UUID) -> ConnectorResult:
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
async def synchronize(self, pharmacy_id: UUID, strategy: str = "incremental", since: str | None = None) -> ConnectorResult:
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
async def heartbeat(self, pharmacy_id: UUID) -> ConnectorResult:
|
||||
...
|
||||
@@ -0,0 +1,3 @@
|
||||
from src.connectors.farmatic.farmatic_connector import FarmaticConnector
|
||||
|
||||
__all__ = ["FarmaticConnector"]
|
||||
@@ -0,0 +1,122 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
from src.connectors.sdk.base_sdk import SDKConnector
|
||||
from src.connectors.sdk.context import ConnectorContext
|
||||
from src.connectors.base import ConnectorResult
|
||||
from src.core.exceptions import ConnectorException
|
||||
from src.core.logging import get_logger
|
||||
from src.core.metrics import CONNECTOR_OPERATIONS
|
||||
|
||||
logger = get_logger("farmatic_connector")
|
||||
|
||||
|
||||
class FarmaticConnector(SDKConnector):
|
||||
connector_type: str = "farmatic"
|
||||
|
||||
def configure(self, config: dict[str, Any]) -> None:
|
||||
self._base_url = config.get("base_url", "https://erp.farmatic.local/api/v1")
|
||||
self._timeout = config.get("timeout", 45.0)
|
||||
super().configure(config)
|
||||
|
||||
async def get_stock(self, pharmacy_id: UUID, medication_id: UUID | None = None, nregistro: str | None = None) -> ConnectorResult:
|
||||
try:
|
||||
params: dict[str, Any] = {"pharmacy": str(pharmacy_id)}
|
||||
if nregistro:
|
||||
params["nregistro"] = nregistro
|
||||
if medication_id:
|
||||
params["medication_id"] = str(medication_id)
|
||||
|
||||
response = await self._request("GET", "/stock", params=params)
|
||||
data = response.json()
|
||||
|
||||
self._record_metric("get_stock", "success")
|
||||
return ConnectorResult(success=True, data=data)
|
||||
except Exception as exc:
|
||||
self._record_metric("get_stock", "error")
|
||||
logger.error("farmatic_get_stock_error", extra={"pharmacy_id": str(pharmacy_id), "error": str(exc)})
|
||||
return ConnectorResult(success=False, error=str(exc))
|
||||
|
||||
async def reserve(self, pharmacy_id: UUID, medication_id: UUID, quantity: int, idempotency_key: str | None = None) -> ConnectorResult:
|
||||
try:
|
||||
payload: dict[str, Any] = {
|
||||
"pharmacy_id": str(pharmacy_id),
|
||||
"medication_id": str(medication_id),
|
||||
"quantity": quantity,
|
||||
}
|
||||
if idempotency_key:
|
||||
payload["idempotency_key"] = idempotency_key
|
||||
|
||||
response = await self._request("POST", "/reservations", json=payload)
|
||||
data = response.json()
|
||||
|
||||
self._record_metric("reserve", "success")
|
||||
return ConnectorResult(success=True, data=data)
|
||||
except Exception as exc:
|
||||
self._record_metric("reserve", "error")
|
||||
logger.error("farmatic_reserve_error", extra={"pharmacy_id": str(pharmacy_id), "error": str(exc)})
|
||||
return ConnectorResult(success=False, error=str(exc))
|
||||
|
||||
async def cancel_reservation(self, pharmacy_id: UUID, reservation_id: str) -> ConnectorResult:
|
||||
try:
|
||||
response = await self._request("DELETE", f"/reservations/{reservation_id}", json={"pharmacy_id": str(pharmacy_id)})
|
||||
data = response.json()
|
||||
|
||||
self._record_metric("cancel_reservation", "success")
|
||||
return ConnectorResult(success=True, data=data)
|
||||
except Exception as exc:
|
||||
self._record_metric("cancel_reservation", "error")
|
||||
logger.error("farmatic_cancel_error", extra={"reservation_id": reservation_id, "error": str(exc)})
|
||||
return ConnectorResult(success=False, error=str(exc))
|
||||
|
||||
async def pharmacy_information(self, pharmacy_id: UUID) -> ConnectorResult:
|
||||
try:
|
||||
response = await self._request("GET", f"/pharmacies/{pharmacy_id}")
|
||||
data = response.json()
|
||||
|
||||
self._record_metric("pharmacy_information", "success")
|
||||
return ConnectorResult(success=True, data=data)
|
||||
except Exception as exc:
|
||||
self._record_metric("pharmacy_information", "error")
|
||||
logger.error("farmatic_pharmacy_info_error", extra={"pharmacy_id": str(pharmacy_id), "error": str(exc)})
|
||||
return ConnectorResult(success=False, error=str(exc))
|
||||
|
||||
async def synchronize(self, pharmacy_id: UUID, strategy: str = "incremental", since: str | None = None) -> ConnectorResult:
|
||||
try:
|
||||
payload: dict[str, Any] = {
|
||||
"pharmacy_id": str(pharmacy_id),
|
||||
"strategy": strategy,
|
||||
}
|
||||
if since:
|
||||
payload["since"] = since
|
||||
|
||||
response = await self._request("POST", "/sync", json=payload)
|
||||
data = response.json()
|
||||
|
||||
self._record_metric("synchronize", "success")
|
||||
return ConnectorResult(success=True, data=data)
|
||||
except Exception as exc:
|
||||
self._record_metric("synchronize", "error")
|
||||
logger.error("farmatic_sync_error", extra={"pharmacy_id": str(pharmacy_id), "error": str(exc)})
|
||||
return ConnectorResult(success=False, error=str(exc))
|
||||
|
||||
async def heartbeat(self, pharmacy_id: UUID) -> ConnectorResult:
|
||||
try:
|
||||
response = await self._request("GET", "/health")
|
||||
data = response.json()
|
||||
data["connector_type"] = self.connector_type
|
||||
data["pharmacy_id"] = str(pharmacy_id)
|
||||
data["circuit_breaker"] = self._circuit_breaker.state.value
|
||||
|
||||
self._record_metric("heartbeat", "success")
|
||||
return ConnectorResult(success=True, data=data)
|
||||
except Exception as exc:
|
||||
self._record_metric("heartbeat", "error")
|
||||
return ConnectorResult(
|
||||
success=False,
|
||||
error=str(exc),
|
||||
metadata={"circuit_breaker": self._circuit_breaker.state.value},
|
||||
)
|
||||
@@ -0,0 +1,3 @@
|
||||
from .mock_connector import MockConnector
|
||||
|
||||
__all__ = ["MockConnector"]
|
||||
@@ -0,0 +1,215 @@
|
||||
import asyncio
|
||||
import random
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
from src.connectors.base import BaseInventoryConnector, ConnectorResult
|
||||
from src.core.logging import get_logger
|
||||
from src.core.metrics import CONNECTOR_OPERATIONS
|
||||
|
||||
logger = get_logger("mock_connector")
|
||||
|
||||
|
||||
class MockConnector(BaseInventoryConnector):
|
||||
connector_type: str = "mock"
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._simulate_latency: bool = True
|
||||
self._latency_range_ms: tuple[int, int] = (20, 150)
|
||||
self._error_rate: float = 0.0
|
||||
self._stock_data: dict[str, dict[str, Any]] = {}
|
||||
self._reservations: dict[str, dict[str, Any]] = {}
|
||||
self._pharmacies: dict[str, dict[str, Any]] = {}
|
||||
self._init_mock_data()
|
||||
|
||||
def configure(self, config: dict[str, Any]) -> None:
|
||||
self._simulate_latency = config.get("simulate_latency", True)
|
||||
self._latency_range_ms = tuple(config.get("latency_range_ms", [20, 150]))
|
||||
self._error_rate = config.get("error_rate", 0.0)
|
||||
|
||||
def _init_mock_data(self) -> None:
|
||||
medicamentos = [
|
||||
("00001", "Ibuprofeno 600mg", 100),
|
||||
("00002", "Paracetamol 1g", 50),
|
||||
("00003", "Amoxicilina 500mg", 200),
|
||||
("00004", "Omeprazol 20mg", 75),
|
||||
("00005", "Loratadina 10mg", 120),
|
||||
("00006", "Diazepam 5mg", 30),
|
||||
("00007", "Metformina 850mg", 250),
|
||||
("00008", "Atorvastatina 20mg", 80),
|
||||
("00009", "Salbutamol Inhalador", 15),
|
||||
("00010", "Insulina Glargina", 40),
|
||||
]
|
||||
for nregistro, name, stock in medicamentos:
|
||||
reserved = random.randint(0, max(stock // 10, 1))
|
||||
self._stock_data[nregistro] = {
|
||||
"nregistro": nregistro,
|
||||
"name": name,
|
||||
"stock": stock,
|
||||
"reserved_stock": reserved,
|
||||
"available_stock": stock - reserved,
|
||||
"price": round(random.uniform(2.5, 45.0), 2),
|
||||
"last_updated": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
|
||||
self._pharmacies["default"] = {
|
||||
"code": "FARM-001",
|
||||
"name": "Farmacia Mock Central",
|
||||
"address": "Calle Mayor 15, Madrid",
|
||||
"phone": "+34912345678",
|
||||
"email": "central@mock-farmacia.es",
|
||||
"opening_hours": {
|
||||
"mon_fri": ["09:00", "21:30"],
|
||||
"sat": ["10:00", "14:00"],
|
||||
"sun": "closed",
|
||||
},
|
||||
"latitude": 40.4168,
|
||||
"longitude": -3.7038,
|
||||
}
|
||||
self._pharmacies["second"] = {
|
||||
"code": "FARM-002",
|
||||
"name": "Farmacia Mock Norte",
|
||||
"address": "Av. de la Ilustración 1, Madrid",
|
||||
"phone": "+34915432100",
|
||||
"email": "norte@mock-farmacia.es",
|
||||
"opening_hours": {
|
||||
"mon_fri": ["08:30", "22:00"],
|
||||
"sat": ["09:00", "22:00"],
|
||||
"sun": ["10:00", "14:00"],
|
||||
},
|
||||
"latitude": 40.4722,
|
||||
"longitude": -3.6934,
|
||||
}
|
||||
|
||||
async def _maybe_delay(self) -> None:
|
||||
if self._simulate_latency:
|
||||
low, high = self._latency_range_ms
|
||||
delay = random.randint(low, high) / 1000.0
|
||||
await asyncio.sleep(delay)
|
||||
|
||||
def _maybe_error(self) -> None:
|
||||
if self._error_rate > 0 and random.random() < self._error_rate:
|
||||
error_types = [
|
||||
("TIMEOUT", "Connection timed out"),
|
||||
("CONNECTION_REFUSED", "ERP server refused connection"),
|
||||
("AUTH_FAILED", "Authentication failed"),
|
||||
("RATE_LIMITED", "ERP rate limit exceeded"),
|
||||
("INTERNAL_ERROR", "ERP internal server error"),
|
||||
]
|
||||
code, msg = random.choice(error_types)
|
||||
from src.core.exceptions import ConnectorException
|
||||
raise ConnectorException(self.connector_type, msg, details={"error_code": code})
|
||||
|
||||
def _metric(self, operation: str, status: str) -> None:
|
||||
CONNECTOR_OPERATIONS.labels(connector_type=self.connector_type, operation=operation, status=status).inc()
|
||||
|
||||
async def get_stock(self, pharmacy_id: UUID, medication_id: UUID | None = None, nregistro: str | None = None) -> ConnectorResult:
|
||||
await self._maybe_delay()
|
||||
self._maybe_error()
|
||||
logger.info("mock_get_stock", extra={"pharmacy_id": str(pharmacy_id), "nregistro": nregistro})
|
||||
|
||||
if nregistro and nregistro in self._stock_data:
|
||||
data = dict(self._stock_data[nregistro])
|
||||
data["pharmacy_id"] = str(pharmacy_id)
|
||||
self._metric("get_stock", "success")
|
||||
return ConnectorResult(success=True, data=data)
|
||||
|
||||
if nregistro is None and medication_id is None:
|
||||
results = [dict(v) for v in self._stock_data.values()]
|
||||
for r in results:
|
||||
r["pharmacy_id"] = str(pharmacy_id)
|
||||
self._metric("get_stock", "success")
|
||||
return ConnectorResult(success=True, data={"items": results, "total": len(results)})
|
||||
|
||||
self._metric("get_stock", "not_found")
|
||||
return ConnectorResult(success=False, error=f"Medication '{nregistro}' not found in mock data")
|
||||
|
||||
async def reserve(self, pharmacy_id: UUID, medication_id: UUID, quantity: int, idempotency_key: str | None = None) -> ConnectorResult:
|
||||
await self._maybe_delay()
|
||||
self._maybe_error()
|
||||
logger.info("mock_reserve", extra={"pharmacy_id": str(pharmacy_id), "quantity": quantity})
|
||||
|
||||
if idempotency_key and idempotency_key in self._reservations:
|
||||
self._metric("reserve", "idempotent")
|
||||
return ConnectorResult(success=True, data=self._reservations[idempotency_key])
|
||||
|
||||
reservation_id = f"mock-res-{idempotency_key or random.randint(100000, 999999)}"
|
||||
reservation_data = {
|
||||
"reservation_id": reservation_id,
|
||||
"status": "confirmed",
|
||||
"quantity": quantity,
|
||||
"pharmacy_id": str(pharmacy_id),
|
||||
"medication_id": str(medication_id),
|
||||
"confirmed_at": datetime.now(timezone.utc).isoformat(),
|
||||
"expires_at": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
self._reservations[reservation_id] = reservation_data
|
||||
if idempotency_key:
|
||||
self._reservations[idempotency_key] = reservation_data
|
||||
|
||||
self._metric("reserve", "success")
|
||||
return ConnectorResult(success=True, data=reservation_data)
|
||||
|
||||
async def cancel_reservation(self, pharmacy_id: UUID, reservation_id: str) -> ConnectorResult:
|
||||
await self._maybe_delay()
|
||||
self._maybe_error()
|
||||
logger.info("mock_cancel_reservation", extra={"reservation_id": reservation_id})
|
||||
|
||||
if reservation_id in self._reservations:
|
||||
self._reservations[reservation_id]["status"] = "cancelled"
|
||||
self._metric("cancel_reservation", "success")
|
||||
return ConnectorResult(success=True, data={"reservation_id": reservation_id, "status": "cancelled"})
|
||||
|
||||
self._metric("cancel_reservation", "not_found")
|
||||
return ConnectorResult(success=False, error=f"Reservation '{reservation_id}' not found")
|
||||
|
||||
async def pharmacy_information(self, pharmacy_id: UUID) -> ConnectorResult:
|
||||
await self._maybe_delay()
|
||||
self._maybe_error()
|
||||
logger.info("mock_pharmacy_information", extra={"pharmacy_id": str(pharmacy_id)})
|
||||
|
||||
pharmacy = list(self._pharmacies.values())[0]
|
||||
result = dict(pharmacy)
|
||||
result["pharmacy_id"] = str(pharmacy_id)
|
||||
self._metric("pharmacy_information", "success")
|
||||
return ConnectorResult(success=True, data=result)
|
||||
|
||||
async def synchronize(self, pharmacy_id: UUID, strategy: str = "incremental", since: str | None = None) -> ConnectorResult:
|
||||
await self._maybe_delay()
|
||||
self._maybe_error()
|
||||
logger.info("mock_synchronize", extra={"pharmacy_id": str(pharmacy_id), "strategy": strategy, "since": since})
|
||||
|
||||
items_total = len(self._stock_data)
|
||||
items_processed = items_total
|
||||
items_failed = random.randint(0, min(2, items_total)) if self._error_rate > 0 else 0
|
||||
if items_failed > 0:
|
||||
items_processed = items_total - items_failed
|
||||
|
||||
self._metric("synchronize", "success")
|
||||
return ConnectorResult(
|
||||
success=True,
|
||||
data={
|
||||
"items_total": items_total,
|
||||
"items_processed": items_processed,
|
||||
"items_failed": items_failed,
|
||||
"strategy": strategy,
|
||||
"synced_at": datetime.now(timezone.utc).isoformat(),
|
||||
"pharmacy_id": str(pharmacy_id),
|
||||
},
|
||||
)
|
||||
|
||||
async def heartbeat(self, pharmacy_id: UUID) -> ConnectorResult:
|
||||
self._metric("heartbeat", "success")
|
||||
return ConnectorResult(
|
||||
success=True,
|
||||
data={
|
||||
"status": "healthy",
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
"connector_type": self.connector_type,
|
||||
"pharmacy_id": str(pharmacy_id),
|
||||
"circuit_breaker": "closed",
|
||||
"stock_items": len(self._stock_data),
|
||||
"active_reservations": len([r for r in self._reservations.values() if r.get("status") == "confirmed"]),
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,105 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
from src.connectors.base import BaseInventoryConnector, ConnectorResult
|
||||
from src.core.exceptions import ConnectorException
|
||||
from src.core.logging import get_logger
|
||||
from src.core.metrics import CONNECTOR_OPERATIONS
|
||||
from src.domain.entities import ConnectorStatus, ERPType
|
||||
from src.infrastructure.cache.redis_cache import redis_cache
|
||||
from src.infrastructure.database.repositories import ConnectorConfigRepository, ConnectorRepository
|
||||
from src.infrastructure.messaging.rabbitmq import rabbitmq_client
|
||||
|
||||
logger = get_logger("connector_registry")
|
||||
|
||||
_BUILTIN_CONNECTORS: dict[str, str] = {
|
||||
ERPType.MOCK.value: "src.connectors.mock.mock_connector.MockConnector",
|
||||
ERPType.FARMATIC.value: "src.connectors.farmatic.farmatic_connector.FarmaticConnector",
|
||||
ERPType.CUSTOM_REST.value: "src.connectors.rest.rest_connector.RESTConnector",
|
||||
}
|
||||
|
||||
|
||||
class ConnectorRegistry:
|
||||
_instance: ConnectorRegistry | None = None
|
||||
_connectors: dict[str, BaseInventoryConnector]
|
||||
_instances: dict[UUID, BaseInventoryConnector]
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._connectors = {}
|
||||
self._instances = {}
|
||||
|
||||
@classmethod
|
||||
def get_registry(cls) -> ConnectorRegistry:
|
||||
if cls._instance is None:
|
||||
cls._instance = cls()
|
||||
return cls._instance
|
||||
|
||||
def register(self, connector_type: str, connector: BaseInventoryConnector) -> None:
|
||||
self._connectors[connector_type] = connector
|
||||
logger.info("connector_registered", extra={"connector_type": connector_type})
|
||||
|
||||
def unregister(self, connector_type: str) -> None:
|
||||
self._connectors.pop(connector_type, None)
|
||||
logger.info("connector_unregistered", extra={"connector_type": connector_type})
|
||||
|
||||
def get_connector_class(self, connector_type: str) -> type[BaseInventoryConnector]:
|
||||
if connector_type in self._connectors:
|
||||
return type(self._connectors[connector_type])
|
||||
|
||||
class_path = _BUILTIN_CONNECTORS.get(connector_type)
|
||||
if not class_path:
|
||||
raise ConnectorException(connector_type, f"No connector registered for type '{connector_type}'")
|
||||
|
||||
try:
|
||||
module_path, class_name = class_path.rsplit(".", 1)
|
||||
module = importlib.import_module(module_path)
|
||||
cls = getattr(module, class_name)
|
||||
self._connectors[connector_type] = cls()
|
||||
return cls
|
||||
except (ImportError, AttributeError) as e:
|
||||
raise ConnectorException(connector_type, f"Failed to load connector '{connector_type}': {e}") from e
|
||||
|
||||
def create_instance(self, connector_type: str, config: dict[str, Any] | None = None) -> BaseInventoryConnector:
|
||||
cls = self.get_connector_class(connector_type)
|
||||
instance = cls()
|
||||
if config and hasattr(instance, "configure"):
|
||||
instance.configure(config)
|
||||
return instance
|
||||
|
||||
def get_or_create_instance(self, connector_id: UUID, connector_type: str, config: dict[str, Any] | None = None) -> BaseInventoryConnector:
|
||||
if connector_id in self._instances:
|
||||
return self._instances[connector_id]
|
||||
instance = self.create_instance(connector_type, config)
|
||||
self._instances[connector_id] = instance
|
||||
return instance
|
||||
|
||||
def remove_instance(self, connector_id: UUID) -> None:
|
||||
self._instances.pop(connector_id, None)
|
||||
|
||||
def list_registered_types(self) -> list[str]:
|
||||
all_types = set(self._connectors.keys()) | set(_BUILTIN_CONNECTORS.keys())
|
||||
return sorted(all_types)
|
||||
|
||||
@staticmethod
|
||||
async def record_heartbeat(repo: ConnectorRepository, connector_id: UUID) -> None:
|
||||
entity = await repo.get_by_id(connector_id)
|
||||
if entity:
|
||||
entity.last_heartbeat_at = datetime.now(timezone.utc)
|
||||
entity.status = ConnectorStatus.ACTIVE
|
||||
entity.last_error = None
|
||||
await repo.update(entity)
|
||||
|
||||
@staticmethod
|
||||
async def record_error(repo: ConnectorRepository, connector_id: UUID, error: str) -> None:
|
||||
entity = await repo.get_by_id(connector_id)
|
||||
if entity:
|
||||
entity.last_error = error
|
||||
entity.status = ConnectorStatus.ERROR
|
||||
await repo.update(entity)
|
||||
|
||||
|
||||
connector_registry = ConnectorRegistry.get_registry()
|
||||
@@ -0,0 +1,3 @@
|
||||
from src.connectors.rest.rest_connector import RESTConnector
|
||||
|
||||
__all__ = ["RESTConnector"]
|
||||
@@ -0,0 +1,103 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
from src.connectors.sdk.base_sdk import SDKConnector
|
||||
from src.connectors.base import ConnectorResult
|
||||
from src.core.logging import get_logger
|
||||
|
||||
logger = get_logger("rest_connector")
|
||||
|
||||
|
||||
class RESTConnector(SDKConnector):
|
||||
connector_type: str = "custom_rest"
|
||||
|
||||
_STOCK_PATH: str = "/stock"
|
||||
_RESERVE_PATH: str = "/reservations"
|
||||
_PHARMACY_PATH: str = "/pharmacy"
|
||||
_SYNC_PATH: str = "/sync"
|
||||
_HEALTH_PATH: str = "/health"
|
||||
|
||||
def configure(self, config: dict[str, Any]) -> None:
|
||||
self._base_url = config.get("base_url", "")
|
||||
self._timeout = config.get("timeout", 30.0)
|
||||
self._STOCK_PATH = config.get("stock_path", self._STOCK_PATH)
|
||||
self._RESERVE_PATH = config.get("reserve_path", self._RESERVE_PATH)
|
||||
self._PHARMACY_PATH = config.get("pharmacy_path", self._PHARMACY_PATH)
|
||||
self._SYNC_PATH = config.get("sync_path", self._SYNC_PATH)
|
||||
self._HEALTH_PATH = config.get("health_path", self._HEALTH_PATH)
|
||||
super().configure(config)
|
||||
|
||||
async def get_stock(self, pharmacy_id: UUID, medication_id: UUID | None = None, nregistro: str | None = None) -> ConnectorResult:
|
||||
try:
|
||||
params: dict[str, Any] = {"pharmacy_id": str(pharmacy_id)}
|
||||
if nregistro:
|
||||
params["nregistro"] = nregistro
|
||||
if medication_id:
|
||||
params["medication_id"] = str(medication_id)
|
||||
response = await self._request("GET", self._STOCK_PATH, params=params)
|
||||
self._record_metric("get_stock", "success")
|
||||
return ConnectorResult(success=True, data=response.json())
|
||||
except Exception as exc:
|
||||
self._record_metric("get_stock", "error")
|
||||
return ConnectorResult(success=False, error=str(exc))
|
||||
|
||||
async def reserve(self, pharmacy_id: UUID, medication_id: UUID, quantity: int, idempotency_key: str | None = None) -> ConnectorResult:
|
||||
try:
|
||||
payload: dict[str, Any] = {
|
||||
"pharmacy_id": str(pharmacy_id),
|
||||
"medication_id": str(medication_id),
|
||||
"quantity": quantity,
|
||||
}
|
||||
if idempotency_key:
|
||||
payload["idempotency_key"] = idempotency_key
|
||||
response = await self._request("POST", self._RESERVE_PATH, json=payload)
|
||||
self._record_metric("reserve", "success")
|
||||
return ConnectorResult(success=True, data=response.json())
|
||||
except Exception as exc:
|
||||
self._record_metric("reserve", "error")
|
||||
return ConnectorResult(success=False, error=str(exc))
|
||||
|
||||
async def cancel_reservation(self, pharmacy_id: UUID, reservation_id: str) -> ConnectorResult:
|
||||
try:
|
||||
response = await self._request("DELETE", f"{self._RESERVE_PATH}/{reservation_id}", json={"pharmacy_id": str(pharmacy_id)})
|
||||
self._record_metric("cancel_reservation", "success")
|
||||
return ConnectorResult(success=True, data=response.json())
|
||||
except Exception as exc:
|
||||
self._record_metric("cancel_reservation", "error")
|
||||
return ConnectorResult(success=False, error=str(exc))
|
||||
|
||||
async def pharmacy_information(self, pharmacy_id: UUID) -> ConnectorResult:
|
||||
try:
|
||||
response = await self._request("GET", f"{self._PHARMACY_PATH}/{pharmacy_id}")
|
||||
self._record_metric("pharmacy_information", "success")
|
||||
return ConnectorResult(success=True, data=response.json())
|
||||
except Exception as exc:
|
||||
self._record_metric("pharmacy_information", "error")
|
||||
return ConnectorResult(success=False, error=str(exc))
|
||||
|
||||
async def synchronize(self, pharmacy_id: UUID, strategy: str = "incremental", since: str | None = None) -> ConnectorResult:
|
||||
try:
|
||||
payload: dict[str, Any] = {"pharmacy_id": str(pharmacy_id), "strategy": strategy}
|
||||
if since:
|
||||
payload["since"] = since
|
||||
response = await self._request("POST", self._SYNC_PATH, json=payload)
|
||||
self._record_metric("synchronize", "success")
|
||||
return ConnectorResult(success=True, data=response.json())
|
||||
except Exception as exc:
|
||||
self._record_metric("synchronize", "error")
|
||||
return ConnectorResult(success=False, error=str(exc))
|
||||
|
||||
async def heartbeat(self, pharmacy_id: UUID) -> ConnectorResult:
|
||||
try:
|
||||
response = await self._request("GET", self._HEALTH_PATH)
|
||||
data = response.json()
|
||||
data["connector_type"] = self.connector_type
|
||||
data["pharmacy_id"] = str(pharmacy_id)
|
||||
data["circuit_breaker"] = self._circuit_breaker.state.value
|
||||
self._record_metric("heartbeat", "success")
|
||||
return ConnectorResult(success=True, data=data)
|
||||
except Exception as exc:
|
||||
self._record_metric("heartbeat", "error")
|
||||
return ConnectorResult(success=False, error=str(exc))
|
||||
@@ -0,0 +1,19 @@
|
||||
from .context import ConnectorContext
|
||||
from .auth import AuthHandler, BasicAuthHandler, BearerAuthHandler, APIKeyAuthHandler, OAuth2Handler
|
||||
from .retry import RetryPolicy, with_retry
|
||||
from .circuit_breaker import CircuitBreaker, CircuitState
|
||||
from .base_sdk import SDKConnector
|
||||
|
||||
__all__ = [
|
||||
"APIKeyAuthHandler",
|
||||
"AuthHandler",
|
||||
"BasicAuthHandler",
|
||||
"BearerAuthHandler",
|
||||
"CircuitBreaker",
|
||||
"CircuitState",
|
||||
"ConnectorContext",
|
||||
"OAuth2Handler",
|
||||
"RetryPolicy",
|
||||
"SDKConnector",
|
||||
"with_retry",
|
||||
]
|
||||
@@ -0,0 +1,123 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
from src.connectors.sdk.context import ConnectorContext
|
||||
from src.core.logging import get_logger
|
||||
|
||||
logger = get_logger("connector_auth")
|
||||
|
||||
|
||||
class AuthHandler(ABC):
|
||||
@abstractmethod
|
||||
async def apply(self, request: httpx.Request, context: ConnectorContext) -> httpx.Request:
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
async def refresh(self, context: ConnectorContext) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
class BasicAuthHandler(AuthHandler):
|
||||
def __init__(self, username_key: str = "username", password_key: str = "password") -> None:
|
||||
self._username_key = username_key
|
||||
self._password_key = password_key
|
||||
|
||||
async def apply(self, request: httpx.Request, context: ConnectorContext) -> httpx.Request:
|
||||
import base64
|
||||
|
||||
username = context.get_secret(self._username_key) or context.get_config(self._username_key, "")
|
||||
password = context.get_secret(self._password_key) or context.get_config(self._password_key, "")
|
||||
credentials = base64.b64encode(f"{username}:{password}".encode()).decode()
|
||||
request.headers["Authorization"] = f"Basic {credentials}"
|
||||
return request
|
||||
|
||||
async def refresh(self, context: ConnectorContext) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
class BearerAuthHandler(AuthHandler):
|
||||
def __init__(self, token_key: str = "access_token") -> None:
|
||||
self._token_key = token_key
|
||||
self._token: str | None = None
|
||||
|
||||
async def apply(self, request: httpx.Request, context: ConnectorContext) -> httpx.Request:
|
||||
token = self._token or context.get_secret(self._token_key) or context.get_config(self._token_key, "")
|
||||
if token:
|
||||
request.headers["Authorization"] = f"Bearer {token}"
|
||||
return request
|
||||
|
||||
async def refresh(self, context: ConnectorContext) -> bool:
|
||||
self._token = None
|
||||
return True
|
||||
|
||||
|
||||
class APIKeyAuthHandler(AuthHandler):
|
||||
def __init__(self, header_name: str = "X-API-Key", key_secret: str = "api_key") -> None:
|
||||
self._header_name = header_name
|
||||
self._key_secret = key_secret
|
||||
|
||||
async def apply(self, request: httpx.Request, context: ConnectorContext) -> httpx.Request:
|
||||
api_key = context.get_secret(self._key_secret) or context.get_config(self._key_secret, "")
|
||||
if api_key:
|
||||
request.headers[self._header_name] = api_key
|
||||
return request
|
||||
|
||||
async def refresh(self, context: ConnectorContext) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
class OAuth2Handler(AuthHandler):
|
||||
def __init__(
|
||||
self,
|
||||
token_url_key: str = "token_url",
|
||||
client_id_key: str = "client_id",
|
||||
client_secret_key: str = "client_secret",
|
||||
) -> None:
|
||||
self._token_url_key = token_url_key
|
||||
self._client_id_key = client_id_key
|
||||
self._client_secret_key = client_secret_key
|
||||
self._access_token: str | None = None
|
||||
self._token_expires: float = 0
|
||||
|
||||
async def apply(self, request: httpx.Request, context: ConnectorContext) -> httpx.Request:
|
||||
import time
|
||||
|
||||
if not self._access_token or time.time() >= self._token_expires:
|
||||
await self._fetch_token(context)
|
||||
|
||||
if self._access_token:
|
||||
request.headers["Authorization"] = f"Bearer {self._access_token}"
|
||||
return request
|
||||
|
||||
async def _fetch_token(self, context: ConnectorContext) -> None:
|
||||
import time
|
||||
|
||||
token_url = context.require_config(self._token_url_key)
|
||||
client_id = context.require_secret(self._client_id_key)
|
||||
client_secret = context.require_secret(self._client_secret_key)
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
resp = await client.post(
|
||||
token_url,
|
||||
data={
|
||||
"grant_type": "client_credentials",
|
||||
"client_id": client_id,
|
||||
"client_secret": client_secret,
|
||||
},
|
||||
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
self._access_token = data["access_token"]
|
||||
self._token_expires = time.time() + data.get("expires_in", 3600) - 30
|
||||
|
||||
logger.info("oauth2_token_acquired", extra={"token_url": token_url})
|
||||
|
||||
async def refresh(self, context: ConnectorContext) -> bool:
|
||||
self._access_token = None
|
||||
self._token_expires = 0
|
||||
return True
|
||||
@@ -0,0 +1,137 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import abstractmethod
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
import httpx
|
||||
|
||||
from src.connectors.base import BaseInventoryConnector, ConnectorResult
|
||||
from src.connectors.sdk.auth import AuthHandler, BearerAuthHandler
|
||||
from src.connectors.sdk.circuit_breaker import CircuitBreaker
|
||||
from src.connectors.sdk.context import ConnectorContext
|
||||
from src.connectors.sdk.retry import RetryPolicy, with_retry
|
||||
from src.core.exceptions import ConnectorException
|
||||
from src.core.logging import get_logger
|
||||
from src.core.metrics import CONNECTOR_OPERATIONS
|
||||
|
||||
logger = get_logger("sdk_connector")
|
||||
|
||||
|
||||
class SDKConnector(BaseInventoryConnector):
|
||||
connector_type: str = "sdk"
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._context: ConnectorContext | None = None
|
||||
self._auth_handler: AuthHandler = BearerAuthHandler()
|
||||
self._retry_policy: RetryPolicy = RetryPolicy()
|
||||
self._circuit_breaker: CircuitBreaker = CircuitBreaker(name=self.connector_type)
|
||||
self._http_client: httpx.AsyncClient | None = None
|
||||
self._base_url: str = ""
|
||||
self._timeout: float = 30.0
|
||||
|
||||
def configure(self, config: dict[str, Any]) -> None:
|
||||
self._base_url = config.get("base_url", "")
|
||||
self._timeout = config.get("timeout", 30.0)
|
||||
auth_type = config.get("auth_type", "bearer")
|
||||
if auth_type == "basic":
|
||||
from src.connectors.sdk.auth import BasicAuthHandler
|
||||
self._auth_handler = BasicAuthHandler()
|
||||
elif auth_type == "api_key":
|
||||
from src.connectors.sdk.auth import APIKeyAuthHandler
|
||||
self._auth_handler = APIKeyAuthHandler(
|
||||
header_name=config.get("api_key_header", "X-API-Key"),
|
||||
key_secret=config.get("api_key_secret_key", "api_key"),
|
||||
)
|
||||
elif auth_type == "oauth2":
|
||||
from src.connectors.sdk.auth import OAuth2Handler
|
||||
self._auth_handler = OAuth2Handler()
|
||||
|
||||
cb_config = config.get("circuit_breaker", {})
|
||||
if cb_config:
|
||||
self._circuit_breaker = CircuitBreaker(
|
||||
name=self.connector_type,
|
||||
failure_threshold=cb_config.get("failure_threshold", 5),
|
||||
recovery_timeout=cb_config.get("recovery_timeout", 30.0),
|
||||
)
|
||||
|
||||
retry_config = config.get("retry", {})
|
||||
if retry_config:
|
||||
self._retry_policy = RetryPolicy(
|
||||
max_attempts=retry_config.get("max_attempts", 3),
|
||||
base_delay=retry_config.get("base_delay", 1.0),
|
||||
max_delay=retry_config.get("max_delay", 30.0),
|
||||
)
|
||||
|
||||
def set_context(self, context: ConnectorContext) -> None:
|
||||
self._context = context
|
||||
if context.config:
|
||||
self.configure(context.config)
|
||||
|
||||
@property
|
||||
def http_client(self) -> httpx.AsyncClient:
|
||||
if self._http_client is None or self._http_client.is_closed:
|
||||
self._http_client = httpx.AsyncClient(
|
||||
base_url=self._base_url,
|
||||
timeout=self._timeout,
|
||||
)
|
||||
return self._http_client
|
||||
|
||||
async def _request(
|
||||
self,
|
||||
method: str,
|
||||
path: str,
|
||||
*,
|
||||
json: dict[str, Any] | None = None,
|
||||
params: dict[str, Any] | None = None,
|
||||
headers: dict[str, str] | None = None,
|
||||
) -> httpx.Response:
|
||||
if not self._context:
|
||||
raise ConnectorException(self.connector_type, "Connector context not set. Call set_context() first.")
|
||||
|
||||
async def _do_request() -> httpx.Response:
|
||||
request = self.http_client.build_request(method, path, json=json, params=params, headers=headers)
|
||||
request = await self._auth_handler.apply(request, self._context)
|
||||
response = await self.http_client.send(request)
|
||||
response.raise_for_status()
|
||||
return response
|
||||
|
||||
return await self._circuit_breaker.call(
|
||||
with_retry,
|
||||
_do_request,
|
||||
policy=self._retry_policy,
|
||||
connector_type=self.connector_type,
|
||||
operation=f"{method}:{path}",
|
||||
)
|
||||
|
||||
def _record_metric(self, operation: str, status: str) -> None:
|
||||
CONNECTOR_OPERATIONS.labels(connector_type=self.connector_type, operation=operation, status=status).inc()
|
||||
|
||||
async def close(self) -> None:
|
||||
if self._http_client and not self._http_client.is_closed:
|
||||
await self._http_client.aclose()
|
||||
self._http_client = None
|
||||
|
||||
@abstractmethod
|
||||
async def get_stock(self, pharmacy_id: UUID, medication_id: UUID | None = None, nregistro: str | None = None) -> ConnectorResult:
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
async def reserve(self, pharmacy_id: UUID, medication_id: UUID, quantity: int, idempotency_key: str | None = None) -> ConnectorResult:
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
async def cancel_reservation(self, pharmacy_id: UUID, reservation_id: str) -> ConnectorResult:
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
async def pharmacy_information(self, pharmacy_id: UUID) -> ConnectorResult:
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
async def synchronize(self, pharmacy_id: UUID, strategy: str = "incremental", since: str | None = None) -> ConnectorResult:
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
async def heartbeat(self, pharmacy_id: UUID) -> ConnectorResult:
|
||||
...
|
||||
@@ -0,0 +1,106 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from enum import Enum
|
||||
from typing import Any, Callable
|
||||
|
||||
from src.core.exceptions import ConnectorException, ServiceUnavailableException
|
||||
from src.core.logging import get_logger
|
||||
|
||||
logger = get_logger("circuit_breaker")
|
||||
|
||||
|
||||
class CircuitState(str, Enum):
|
||||
CLOSED = "closed"
|
||||
OPEN = "open"
|
||||
HALF_OPEN = "half_open"
|
||||
|
||||
|
||||
class CircuitBreaker:
|
||||
def __init__(
|
||||
self,
|
||||
name: str,
|
||||
failure_threshold: int = 5,
|
||||
recovery_timeout: float = 30.0,
|
||||
half_open_max_calls: int = 1,
|
||||
) -> None:
|
||||
self._name = name
|
||||
self._failure_threshold = failure_threshold
|
||||
self._recovery_timeout = recovery_timeout
|
||||
self._half_open_max_calls = half_open_max_calls
|
||||
|
||||
self._state = CircuitState.CLOSED
|
||||
self._failure_count = 0
|
||||
self._success_count = 0
|
||||
self._last_failure_time: float = 0
|
||||
self._half_open_calls: int = 0
|
||||
|
||||
@property
|
||||
def state(self) -> CircuitState:
|
||||
if self._state == CircuitState.OPEN:
|
||||
if time.monotonic() - self._last_failure_time >= self._recovery_timeout:
|
||||
self._state = CircuitState.HALF_OPEN
|
||||
self._half_open_calls = 0
|
||||
logger.info("circuit_half_open", extra={"name": self._name})
|
||||
return self._state
|
||||
|
||||
async def call(self, func: Callable[..., Any], *args: Any, **kwargs: Any) -> Any:
|
||||
current_state = self.state
|
||||
|
||||
if current_state == CircuitState.OPEN:
|
||||
raise ServiceUnavailableException(
|
||||
f"circuit_breaker:{self._name}",
|
||||
f"Circuit breaker '{self._name}' is open. Retry after {self._recovery_timeout}s.",
|
||||
)
|
||||
|
||||
if current_state == CircuitState.HALF_OPEN:
|
||||
if self._half_open_calls >= self._half_open_max_calls:
|
||||
raise ServiceUnavailableException(
|
||||
f"circuit_breaker:{self._name}",
|
||||
f"Circuit breaker '{self._name}' is half-open with max concurrent calls reached.",
|
||||
)
|
||||
self._half_open_calls += 1
|
||||
|
||||
try:
|
||||
result = await func(*args, **kwargs)
|
||||
self._on_success()
|
||||
return result
|
||||
except Exception as exc:
|
||||
self._on_failure(exc)
|
||||
raise
|
||||
|
||||
def _on_success(self) -> None:
|
||||
if self._state == CircuitState.HALF_OPEN:
|
||||
self._state = CircuitState.CLOSED
|
||||
logger.info("circuit_closed", extra={"name": self._name})
|
||||
self._failure_count = 0
|
||||
self._success_count += 1
|
||||
|
||||
def _on_failure(self, exc: Exception) -> None:
|
||||
self._failure_count += 1
|
||||
self._last_failure_time = time.monotonic()
|
||||
self._success_count = 0
|
||||
|
||||
if self._state == CircuitState.HALF_OPEN:
|
||||
self._state = CircuitState.OPEN
|
||||
logger.warning("circuit_reopened", extra={"name": self._name, "error": str(exc)})
|
||||
elif self._failure_count >= self._failure_threshold:
|
||||
self._state = CircuitState.OPEN
|
||||
logger.warning("circuit_opened", extra={"name": self._name, "failure_count": self._failure_count})
|
||||
|
||||
def reset(self) -> None:
|
||||
self._state = CircuitState.CLOSED
|
||||
self._failure_count = 0
|
||||
self._success_count = 0
|
||||
self._last_failure_time = 0
|
||||
|
||||
def get_status(self) -> dict[str, Any]:
|
||||
return {
|
||||
"name": self._name,
|
||||
"state": self.state.value,
|
||||
"failure_count": self._failure_count,
|
||||
"success_count": self._success_count,
|
||||
"failure_threshold": self._failure_threshold,
|
||||
"recovery_timeout": self._recovery_timeout,
|
||||
"last_failure_time": self._last_failure_time,
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
|
||||
@dataclass
|
||||
class ConnectorContext:
|
||||
connector_id: UUID
|
||||
pharmacy_id: UUID
|
||||
erp_system_id: UUID
|
||||
connector_type: str
|
||||
config: dict[str, Any] = field(default_factory=dict)
|
||||
secrets: dict[str, str] = field(default_factory=dict)
|
||||
|
||||
_cache_prefix: str = field(default="connector", init=False, repr=False)
|
||||
|
||||
@property
|
||||
def cache_key_prefix(self) -> str:
|
||||
return f"{self._cache_prefix}:{self.connector_type}:{self.pharmacy_id}"
|
||||
|
||||
def cache_key(self, suffix: str) -> str:
|
||||
return f"{self.cache_key_prefix}:{suffix}"
|
||||
|
||||
def get_config(self, key: str, default: Any = None) -> Any:
|
||||
return self.config.get(key, default)
|
||||
|
||||
def get_secret(self, key: str) -> str | None:
|
||||
return self.secrets.get(key)
|
||||
|
||||
def require_secret(self, key: str) -> str:
|
||||
value = self.secrets.get(key)
|
||||
if value is None:
|
||||
raise ValueError(f"Required secret '{key}' not found in connector context")
|
||||
return value
|
||||
|
||||
def require_config(self, key: str) -> Any:
|
||||
value = self.config.get(key)
|
||||
if value is None:
|
||||
raise ValueError(f"Required config '{key}' not found in connector context")
|
||||
return value
|
||||
@@ -0,0 +1,87 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from dataclasses import dataclass, field
|
||||
from functools import wraps
|
||||
from typing import Any, Callable, TypeVar
|
||||
|
||||
from src.core.exceptions import ConnectorException
|
||||
from src.core.logging import get_logger
|
||||
|
||||
logger = get_logger("connector_retry")
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
DEFAULT_RETRYABLE_EXCEPTIONS = (ConnectionError, TimeoutError, OSError)
|
||||
|
||||
|
||||
@dataclass
|
||||
class RetryPolicy:
|
||||
max_attempts: int = 3
|
||||
base_delay: float = 1.0
|
||||
max_delay: float = 30.0
|
||||
exponential_base: float = 2.0
|
||||
jitter: bool = True
|
||||
retryable_exceptions: tuple[type[Exception], ...] = field(default_factory=lambda: DEFAULT_RETRYABLE_EXCEPTIONS)
|
||||
|
||||
def delay_for_attempt(self, attempt: int) -> float:
|
||||
import random
|
||||
|
||||
delay = min(self.base_delay * (self.exponential_base ** (attempt - 1)), self.max_delay)
|
||||
if self.jitter:
|
||||
delay = delay * (0.5 + random.random() * 0.5)
|
||||
return delay
|
||||
|
||||
def should_retry(self, exception: Exception) -> bool:
|
||||
if isinstance(exception, ConnectorException):
|
||||
return False
|
||||
return isinstance(exception, self.retryable_exceptions)
|
||||
|
||||
|
||||
async def with_retry(
|
||||
func: Callable[..., Any],
|
||||
*args: Any,
|
||||
policy: RetryPolicy | None = None,
|
||||
connector_type: str = "unknown",
|
||||
operation: str = "unknown",
|
||||
**kwargs: Any,
|
||||
) -> Any:
|
||||
p = policy or RetryPolicy()
|
||||
last_exception: Exception | None = None
|
||||
|
||||
for attempt in range(1, p.max_attempts + 1):
|
||||
try:
|
||||
result = await func(*args, **kwargs)
|
||||
if attempt > 1:
|
||||
logger.info("retry_succeeded", extra={"connector_type": connector_type, "operation": operation, "attempt": attempt})
|
||||
return result
|
||||
except Exception as exc:
|
||||
last_exception = exc
|
||||
if not p.should_retry(exc) or attempt >= p.max_attempts:
|
||||
break
|
||||
delay = p.delay_for_attempt(attempt)
|
||||
logger.warning(
|
||||
"retry_attempt",
|
||||
extra={
|
||||
"connector_type": connector_type,
|
||||
"operation": operation,
|
||||
"attempt": attempt,
|
||||
"delay_s": round(delay, 2),
|
||||
"error": str(exc),
|
||||
},
|
||||
)
|
||||
await asyncio.sleep(delay)
|
||||
|
||||
raise last_exception # type: ignore[misc]
|
||||
|
||||
|
||||
def retry(policy: RetryPolicy | None = None):
|
||||
p = policy or RetryPolicy()
|
||||
|
||||
def decorator(func: Callable[..., Any]) -> Callable[..., Any]:
|
||||
@wraps(func)
|
||||
async def wrapper(*args: Any, **kwargs: Any) -> Any:
|
||||
connector_type = getattr(args[0], "connector_type", "unknown") if args else "unknown"
|
||||
return await with_retry(func, *args, policy=p, connector_type=connector_type, operation=func.__name__, **kwargs)
|
||||
return wrapper
|
||||
return decorator
|
||||
Reference in New Issue
Block a user