Restructure with Turborepo
Run Tests on Branches / Backend Tests (push) Successful in 3m38s
Run Tests on Branches / Frontend Tests (push) Successful in 3m28s

This commit is contained in:
Antoni Nuñez Romeu
2026-07-06 15:51:53 +02:00
parent f66cafbbc3
commit 190b3d163d
277 changed files with 53253 additions and 0 deletions
@@ -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