Files
g2s-aggregator/app/services/aggregator.py
T
Раис Юсупалиев 8d47917ba2 007 add telemetry
2026-03-08 11:43:15 +03:00

198 lines
7.0 KiB
Python

"""Aggregator service orchestrating providers, cache and domain rules."""
import asyncio
import hashlib
import json
from collections.abc import Callable, Iterable, Sequence
from typing import Protocol
from app.adapters.delivery_providers.base import DeliveryProvider
from app.domain.price import (
DEFAULT_WEIGHT_ROUND_SCALE,
NormalizedDeliveryRequest,
filter_and_sort_prices,
normalize_delivery_request,
)
from app.observability.metrics import get_metrics
from app.observability.tracing import get_tracer
from app.schemas.request import DeliveryRequest
from app.schemas.response import DeliveryPrice
class AggregatorServiceError(RuntimeError):
"""Base exception for AggregatorService failures."""
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 AggregatorService:
def __init__(
self,
providers: Sequence[DeliveryProvider],
cache: PriceCacheProtocol | None = None,
*,
weight_round_scale: int = DEFAULT_WEIGHT_ROUND_SCALE,
filter_and_sort_prices_fn: Callable[[Iterable[object]], list[object]] = (
filter_and_sort_prices
),
) -> None:
self._providers = tuple(providers)
self._cache = cache
self._weight_round_scale = weight_round_scale
self._filter_and_sort_prices = filter_and_sort_prices_fn
async def get_all_prices(self, request: DeliveryRequest) -> list[DeliveryPrice]:
tracer = get_tracer(__name__)
with tracer.start_as_current_span("AggregatorService.get_all_prices") as span:
span.set_attribute("from_city", request.from_city)
span.set_attribute("to_city", request.to_city)
span.set_attribute("weight_kg", float(request.weight_kg))
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_price(
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 price in provider_results
if isinstance(price, DeliveryPrice)
]
filtered_and_sorted = self._filter_and_sort_prices(successful_results)
result = [self._coerce_delivery_price(price) for price in filtered_and_sorted]
span.set_attribute("tariffs_found", len(result))
return result
async def _get_provider_price(
self,
*,
provider: DeliveryProvider,
request: DeliveryRequest,
cache_key: str,
) -> DeliveryPrice:
tracer = get_tracer(__name__)
with tracer.start_as_current_span("DeliveryProvider.get_price") as span:
span.set_attribute("provider", provider.name)
span.set_attribute("from_city", request.from_city)
span.set_attribute("to_city", request.to_city)
span.set_attribute("weight_kg", float(request.weight_kg))
cached_price = await self._get_cached_price(cache_key)
span.set_attribute("cache_hit", cached_price is not None)
if cached_price is not None:
return cached_price
try:
fresh_price = await provider.get_price(request)
except Exception:
get_metrics().record_provider_availability(
provider=provider.name,
is_available=False,
)
raise
get_metrics().record_provider_availability(
provider=provider.name,
is_available=True,
)
await self._set_cached_price(
cache_key,
fresh_price,
ttl=getattr(provider, "cache_ttl_seconds", None),
)
return fresh_price
async def _get_cached_price(self, cache_key: str) -> DeliveryPrice | None:
tracer = get_tracer(__name__)
with tracer.start_as_current_span("PriceCache.get") as span:
if self._cache is None:
span.set_attribute("cache_hit", False)
return None
try:
payload = await self._cache.get(cache_key)
except Exception:
span.set_attribute("cache_hit", False)
return None
if payload is None:
span.set_attribute("cache_hit", False)
return None
try:
price = self._coerce_delivery_price(payload)
except Exception:
span.set_attribute("cache_hit", False)
return None
span.set_attribute("cache_hit", True)
return price
async def _set_cached_price(
self,
cache_key: str,
payload: DeliveryPrice,
*,
ttl: int | None,
) -> None:
tracer = get_tracer(__name__)
with tracer.start_as_current_span("PriceCache.set"):
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) -> DeliveryRequest:
return DeliveryRequest(
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)