Files
payment-processing-test/app/processing.py
T

130 lines
4.3 KiB
Python

import asyncio
import logging
import random
from collections.abc import Awaitable, Callable
from datetime import UTC, datetime
import httpx
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 httpx.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()