Restructure with Turborepo
This commit is contained in:
@@ -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