"""CSE SOAP client and provider adapter.""" import asyncio from collections.abc import Awaitable, Callable import httpx import structlog from app.adapters.delivery_providers.base import DeliveryProvider from app.adapters.delivery_providers.cse.constants import CSE_PROVIDER_NAME from app.adapters.delivery_providers.cse.errors import ( CSEClientError, CSEMappingError, CSERequestError, ) from app.adapters.delivery_providers.cse.mapper import ( map_cse_calc_response, map_cse_calc_response_for_tariff_code, ) from app.adapters.delivery_providers.cse.order_mapper import ( CSEOrderRegistrationParams, CSEOrderRegistrationResult, build_calc_body_for_calculation, build_calc_body_for_payment, map_cse_save_order_request, map_cse_save_order_response, ) from app.adapters.delivery_providers.cse.soap import ( Element, build_envelope, parse_response, ) from app.config import AdapterConfig from app.schemas.payment import InitPaymentRequest from app.schemas.request import DeliveryCalculationRequest from app.schemas.response import DeliveryPrice log = structlog.get_logger(__name__) _SOAP_HEADERS = {"Content-Type": "application/soap+xml; charset=utf-8"} class CSEClient: def __init__( self, http_client: httpx.AsyncClient, *, base_url: str, login: str, password: str, registration_params: CSEOrderRegistrationParams, timeout_seconds: float = 10.0, retry_attempts: int = 2, retry_backoff_seconds: float = 0.2, sleep: Callable[[float], Awaitable[None]] = asyncio.sleep, ) -> None: self._http_client = http_client self._url = base_url self._login = login self._password = password self._registration_params = registration_params self._timeout_seconds = timeout_seconds self._retry_attempts = retry_attempts self._retry_backoff_seconds = retry_backoff_seconds self._sleep = sleep async def calc(self, body: dict[str, Element]) -> Element: return await self._post( "Calc", body, request_error_message="CSE calc request was rejected with status", ) async def save_order( self, request: InitPaymentRequest, order_uuid: str ) -> CSEOrderRegistrationResult: body = map_cse_save_order_request( request, order_uuid, self._registration_params ) root = await self._post( "SaveDocuments", body, request_error_message="CSE SaveDocuments request was rejected with status", ) try: return map_cse_save_order_response(root) except CSEMappingError as exc: raise CSEClientError(str(exc)) from exc async def _post( self, operation: str, body: dict[str, Element], *, request_error_message: str, ) -> Element: envelope = build_envelope( operation, login=self._login, password=self._password, body=body, ) for attempt in range(self._retry_attempts + 1): try: response = await self._http_client.post( self._url, content=envelope, headers=_SOAP_HEADERS, timeout=self._timeout_seconds, ) except (httpx.TimeoutException, httpx.TransportError) as exc: if attempt < self._retry_attempts: await self._sleep(self._retry_delay(attempt)) continue raise CSEClientError( f"CSE {operation} request failed after retry attempts." ) from exc if self._should_retry(response.status_code): if attempt < self._retry_attempts: await self._sleep(self._retry_delay(attempt)) continue log.warning( "cse_request_server_error", operation=operation, status_code=response.status_code, ) raise CSEClientError( f"CSE {operation} request failed with status {response.status_code}." ) if 400 <= response.status_code < 500: log.warning( "cse_request_rejected", operation=operation, status_code=response.status_code, ) raise CSERequestError( f"{request_error_message} {response.status_code}." ) try: root = parse_response(response.text, operation) except ValueError as exc: raise CSEClientError( f"CSE {operation} response payload is invalid." ) from exc self._raise_for_response_error(root, operation, request_error_message) return root raise CSEClientError(f"CSE {operation} request failed unexpectedly.") @staticmethod def _raise_for_response_error( root: Element, operation: str, request_error_message: str, ) -> None: for prop in root.properties: if prop.key == "Error": codes = [item.value for item in prop.items if item.value] log.warning( "cse_response_error", operation=operation, error_codes=codes, ) raise CSERequestError( f"{request_error_message} application error {codes}." ) @staticmethod def _should_retry(status_code: int) -> bool: return status_code == 429 or 500 <= status_code < 600 def _retry_delay(self, attempt: int) -> float: return self._retry_backoff_seconds * (2**attempt) class CSEProvider(DeliveryProvider): name = CSE_PROVIDER_NAME def __init__(self, client: CSEClient, *, cache_ttl_seconds: int = 900) -> None: self._client = client self.cache_ttl_seconds = cache_ttl_seconds @classmethod def from_adapter_config( cls, *, http_client: httpx.AsyncClient, adapter_config: AdapterConfig, ) -> "CSEProvider": client = CSEClient( http_client=http_client, base_url=adapter_config.cse_base_url, login=adapter_config.cse_login, password=adapter_config.cse_password, registration_params=CSEOrderRegistrationParams( payer=adapter_config.cse_payer, payment_method=adapter_config.cse_payment_method, shipping_method=adapter_config.cse_shipping_method, urgency=adapter_config.cse_urgency, ), timeout_seconds=adapter_config.cse_timeout_seconds, retry_attempts=adapter_config.cse_retry_attempts, retry_backoff_seconds=adapter_config.cse_retry_backoff_seconds, ) return cls(client=client, cache_ttl_seconds=adapter_config.cse_cache_ttl_seconds) async def get_prices( self, request: DeliveryCalculationRequest ) -> list[DeliveryPrice]: body = build_calc_body_for_calculation(request) root = await self._client.calc(body) try: return map_cse_calc_response(root) except CSEMappingError as exc: raise CSEClientError("CSE calc response payload is invalid.") from exc async def get_payment_price( self, request: InitPaymentRequest, ) -> DeliveryPrice | None: body = build_calc_body_for_payment(request) root = await self._client.calc(body) try: return map_cse_calc_response_for_tariff_code( root, tariff_code=request.system_data.tariff.tariff_code, ) except CSEMappingError as exc: raise CSEClientError( "CSE payment price validation response payload is invalid." ) from exc async def register_order( self, request: InitPaymentRequest, order_uuid: str ) -> CSEOrderRegistrationResult: return await self._client.save_order(request, order_uuid)