Files
g2s-aggregator/app/services/aggregator.py
T
2026-03-29 02:05:21 +03:00

304 lines
11 KiB
Python

"""Aggregator service orchestrating providers, cache and domain rules."""
import asyncio
import hashlib
import json
from collections.abc import Iterable, Mapping, Sequence
from decimal import Decimal
from typing import Protocol
from app.adapters.address_suggestions.base import (
AddressSuggestionClientError,
AddressSuggestionProvider,
AddressSuggestionRequestError,
)
from app.adapters.delivery_providers.base import DeliveryProvider, ProviderRequestError
from app.domain.price import (
DEFAULT_PROVIDER_PRICE_MULTIPLIER,
DEFAULT_WEIGHT_ROUND_SCALE,
NormalizedDeliveryRequest,
filter_and_sort_prices,
normalize_delivery_request,
)
from app.schemas.order import OrderCreateRequest, OrderCreateResponse
from app.schemas.request import AddressSuggestRequest, DeliveryCalculationRequest
from app.schemas.response import AddressSuggestion, DeliveryPrice
class AggregatorServiceError(RuntimeError):
"""Base exception for AggregatorService failures."""
class InvalidDeliveryRequestError(AggregatorServiceError):
"""Raised when provider rejects delivery request as invalid."""
class UnsupportedAddressSuggestionCountryError(AggregatorServiceError):
"""Raised when address suggestions are not configured for a country."""
class InvalidAddressSuggestRequestError(AggregatorServiceError):
"""Raised when provider rejects address suggestion payload."""
class AddressSuggestionsUnavailableError(AggregatorServiceError):
"""Raised when address suggestions cannot be completed."""
class InvalidOrderCreateRequestError(AggregatorServiceError):
"""Raised when provider rejects order creation payload as invalid."""
class OrderCreationUnavailableError(AggregatorServiceError):
"""Raised when order creation cannot be completed."""
class PriceCacheProtocol(Protocol):
async def get(self, key: str) -> object | None: ...
async def set(self, key: str, value: object, ttl: int | None = None) -> None: ...
class FilterAndSortPricesFn(Protocol):
def __call__(
self,
prices: Iterable[object],
*,
price_multiplier: Decimal = DEFAULT_PROVIDER_PRICE_MULTIPLIER,
parcel_type: object | None = None,
) -> list[object]: ...
class OrderRegistrationAdapterProtocol(Protocol):
async def register_order(
self, request: OrderCreateRequest
) -> OrderCreateResponse: ...
class AggregatorService:
def __init__(
self,
providers: Sequence[DeliveryProvider],
cache: PriceCacheProtocol | None = None,
order_adapter: OrderRegistrationAdapterProtocol | None = None,
address_suggestion_providers: Sequence[AddressSuggestionProvider] = (),
address_suggestion_country_to_provider: Mapping[str, str] | None = None,
*,
weight_round_scale: int = DEFAULT_WEIGHT_ROUND_SCALE,
provider_price_multiplier: Decimal = DEFAULT_PROVIDER_PRICE_MULTIPLIER,
filter_and_sort_prices_fn: FilterAndSortPricesFn = filter_and_sort_prices,
) -> None:
self._providers = tuple(providers)
self._cache = cache
self._order_adapter = order_adapter
self._weight_round_scale = weight_round_scale
self._provider_price_multiplier = provider_price_multiplier
self._filter_and_sort_prices = filter_and_sort_prices_fn
self._address_suggestion_providers: dict[str, AddressSuggestionProvider] = {
provider.name: provider for provider in address_suggestion_providers
}
self._address_suggestion_country_to_provider = (
address_suggestion_country_to_provider or {}
)
async def get_all_prices(
self, request: DeliveryCalculationRequest
) -> list[DeliveryPrice]:
normalized_request = normalize_delivery_request(
request, weight_round_scale=self._weight_round_scale
)
provider_request = self._to_provider_request(normalized_request)
provider_results = await asyncio.gather(
*(
self._get_provider_prices(
provider=provider,
request=provider_request,
cache_key=self._build_cache_key(
provider_name=provider.name,
request=normalized_request,
),
)
for provider in self._providers
),
return_exceptions=True,
)
successful_results = [
price
for provider_prices in provider_results
if isinstance(provider_prices, list)
for price in provider_prices
]
provider_errors = [
error
for error in provider_results
if isinstance(error, Exception)
]
if (
not successful_results
and provider_errors
and any(isinstance(error, ProviderRequestError) for error in provider_errors)
):
raise InvalidDeliveryRequestError(
"Delivery request is invalid for configured providers."
)
filtered_and_sorted = self._filter_and_sort_prices(
successful_results,
price_multiplier=self._provider_price_multiplier,
parcel_type=request.parcel_type,
)
return [self._coerce_delivery_price(price) for price in filtered_and_sorted]
async def suggest_addresses(
self, request: AddressSuggestRequest
) -> list[AddressSuggestion]:
provider = self._resolve_address_suggestion_provider(request.country_code)
try:
suggestions = await provider.suggest(request)
except AddressSuggestionRequestError as exc:
raise InvalidAddressSuggestRequestError(
"Address suggestion request is invalid for the configured provider."
) from exc
except AddressSuggestionClientError as exc:
raise AddressSuggestionsUnavailableError(
"Address suggestions are temporarily unavailable."
) from exc
return [self._coerce_address_suggestion(item) for item in suggestions]
async def create_order(self, request: OrderCreateRequest) -> OrderCreateResponse:
if self._order_adapter is None:
raise OrderCreationUnavailableError(
"Order registration adapter is not configured."
)
try:
created_order = await self._order_adapter.register_order(request)
except ProviderRequestError as exc:
raise InvalidOrderCreateRequestError(
"Order request is invalid for the configured provider."
) from exc
except Exception as exc:
raise OrderCreationUnavailableError(
"Order creation is temporarily unavailable."
) from exc
return OrderCreateResponse.model_validate(
created_order,
from_attributes=True,
)
async def _get_provider_prices(
self,
*,
provider: DeliveryProvider,
request: DeliveryCalculationRequest,
cache_key: str,
) -> list[DeliveryPrice]:
cached_prices = await self._get_cached_prices(cache_key)
if cached_prices is not None:
return cached_prices
fresh_prices = await provider.get_prices(request)
await self._set_cached_prices(
cache_key,
fresh_prices,
ttl=getattr(provider, "cache_ttl_seconds", None),
)
return fresh_prices
async def _get_cached_prices(self, cache_key: str) -> list[DeliveryPrice] | None:
if self._cache is None:
return None
try:
payload = await self._cache.get(cache_key)
except Exception:
return None
if payload is None:
return None
try:
return self._coerce_delivery_prices(payload)
except Exception:
return None
async def _set_cached_prices(
self,
cache_key: str,
payload: list[DeliveryPrice],
*,
ttl: int | None,
) -> None:
if self._cache is None:
return
try:
await self._cache.set(cache_key, payload, ttl=ttl)
except Exception:
return
@staticmethod
def _to_provider_request(
request: NormalizedDeliveryRequest,
) -> DeliveryCalculationRequest:
return DeliveryCalculationRequest(
entity=request.entity,
from_city=request.from_city,
to_city=request.to_city,
weight_kg=request.weight_kg,
length_cm=request.length_cm,
width_cm=request.width_cm,
height_cm=request.height_cm,
)
@staticmethod
def _build_cache_key(provider_name: str, request: NormalizedDeliveryRequest) -> str:
cache_payload = {
"provider": provider_name,
"entity": request.entity,
"from_city": request.from_city,
"to_city": request.to_city,
"weight_kg": str(request.weight_kg),
"length_cm": str(request.length_cm),
"width_cm": str(request.width_cm),
"height_cm": str(request.height_cm),
}
serialized_payload = json.dumps(
cache_payload,
ensure_ascii=True,
sort_keys=True,
separators=(",", ":"),
)
cache_hash = hashlib.sha256(serialized_payload.encode("utf-8")).hexdigest()
return f"delivery-price:{provider_name}:{cache_hash}"
@staticmethod
def _coerce_delivery_price(value: object) -> DeliveryPrice:
return DeliveryPrice.model_validate(value, from_attributes=True)
@classmethod
def _coerce_delivery_prices(cls, value: object) -> list[DeliveryPrice]:
if not isinstance(value, list):
raise TypeError("Cached delivery prices must be a list.")
return [cls._coerce_delivery_price(item) for item in value]
@staticmethod
def _coerce_address_suggestion(value: object) -> AddressSuggestion:
return AddressSuggestion.model_validate(value, from_attributes=True)
def _resolve_address_suggestion_provider(
self, country_code: str
) -> AddressSuggestionProvider:
provider_id = self._address_suggestion_country_to_provider.get(country_code)
if provider_id is None:
raise UnsupportedAddressSuggestionCountryError(
"Address suggestions are not configured for the requested country."
)
provider = self._address_suggestion_providers.get(provider_id)
if provider is None:
raise UnsupportedAddressSuggestionCountryError(
"Address suggestions are not configured for the requested country."
)
return provider