Refactor code structure for improved readability and maintainability
This commit is contained in:
@@ -0,0 +1,129 @@
|
||||
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()
|
||||
Reference in New Issue
Block a user