import asyncio import logging import random from collections.abc import Awaitable, Callable from datetime import UTC, datetime import httpx2 from sqlalchemy import select from sqlalchemy.ext.asyncio import async_sessionmaker from app.config import Settings from app.models import Payment, PaymentStatus from app.schemas import PaymentCreatedEvent, PaymentWebhook logger = logging.getLogger(__name__) class PaymentNotFoundError(Exception): pass class PaymentGateway: def __init__(self, settings: Settings) -> None: self._min_seconds = settings.processing_min_seconds self._max_seconds = settings.processing_max_seconds self._success_rate = settings.processing_success_rate async def charge(self) -> PaymentStatus: await asyncio.sleep(random.uniform(self._min_seconds, self._max_seconds)) if random.random() < self._success_rate: return PaymentStatus.SUCCEEDED return PaymentStatus.FAILED class WebhookClient: def __init__(self, timeout: float) -> None: self._timeout = timeout async def send(self, url: str, payload: PaymentWebhook) -> None: async with httpx2.AsyncClient(timeout=self._timeout) as client: response = await client.post(url, json=payload.model_dump(mode="json")) response.raise_for_status() async def retry_async[T]( operation: Callable[[], Awaitable[T]], *, attempts: int, base_delay: float, sleep: Callable[[float], Awaitable[None]] = asyncio.sleep, ) -> T: for attempt in range(attempts): try: return await operation() except Exception: if attempt == attempts - 1: raise delay = base_delay * (2**attempt) logger.warning( "Payment handling attempt %d/%d failed; retrying in %.2fs", attempt + 1, attempts, delay, exc_info=True, ) await sleep(delay) raise RuntimeError("Retry loop completed without a result") class PaymentProcessor: def __init__( self, session_factory: async_sessionmaker, gateway: PaymentGateway, webhook_client: WebhookClient, settings: Settings, ) -> None: self._session_factory = session_factory self._gateway = gateway self._webhook_client = webhook_client self._attempts = settings.processing_max_attempts self._base_delay = settings.retry_base_delay async def process(self, event: PaymentCreatedEvent) -> None: await retry_async( lambda: self._process_once(event), attempts=self._attempts, base_delay=self._base_delay, ) async def _process_once(self, event: PaymentCreatedEvent) -> None: async with self._session_factory() as session: payment = await session.scalar( select(Payment).where(Payment.id == event.payment_id).with_for_update() ) if payment is None: raise PaymentNotFoundError(str(event.payment_id)) if payment.status is PaymentStatus.PENDING: payment.status = await self._gateway.charge() payment.processed_at = datetime.now(UTC) await session.commit() if payment.webhook_delivered_at is not None: return if payment.processed_at is None: raise RuntimeError("A terminal payment has no processing timestamp") webhook_url = payment.webhook_url webhook = PaymentWebhook( payment_id=payment.id, amount=payment.amount, currency=payment.currency, status=payment.status, processed_at=payment.processed_at, metadata=payment.metadata_, ) await self._webhook_client.send(webhook_url, webhook) async with self._session_factory() as session: payment = await session.scalar( select(Payment).where(Payment.id == event.payment_id).with_for_update() ) if payment is None: raise PaymentNotFoundError(str(event.payment_id)) if payment.webhook_delivered_at is None: payment.webhook_delivered_at = datetime.now(UTC) await session.commit()