Refactor code structure for improved readability and maintainability

This commit is contained in:
k1nq
2026-09-16 13:41:55 +05:00
commit 7d0762ef17
27 changed files with 2718 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
"""Asynchronous payment processing service."""
+40
View File
@@ -0,0 +1,40 @@
import uuid
from typing import Annotated
from fastapi import APIRouter, Depends, Header, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.auth import require_api_key
from app.db import get_session
from app.payments import create_payment, get_payment
from app.schemas import PaymentAccepted, PaymentCreate, PaymentDetails
router = APIRouter(
prefix="/api/v1/payments",
tags=["payments"],
dependencies=[Depends(require_api_key)],
)
@router.post("", response_model=PaymentAccepted, status_code=status.HTTP_202_ACCEPTED)
async def post_payment(
request: PaymentCreate,
idempotency_key: Annotated[
str,
Header(alias="Idempotency-Key", min_length=1, max_length=255),
],
session: Annotated[AsyncSession, Depends(get_session)],
) -> PaymentAccepted:
payment = await create_payment(session, request, idempotency_key)
return PaymentAccepted.model_validate(payment)
@router.get("/{payment_id}", response_model=PaymentDetails)
async def read_payment(
payment_id: uuid.UUID,
session: Annotated[AsyncSession, Depends(get_session)],
) -> PaymentDetails:
payment = await get_payment(session, payment_id)
if payment is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Payment not found")
return PaymentDetails.model_validate(payment)
+20
View File
@@ -0,0 +1,20 @@
import secrets
from typing import Annotated
from fastapi import Depends, HTTPException, status
from fastapi.security import APIKeyHeader
from app.config import Settings, get_settings
api_key_header = APIKeyHeader(name="X-API-Key", auto_error=False)
async def require_api_key(
supplied_key: Annotated[str | None, Depends(api_key_header)],
current_settings: Annotated[Settings, Depends(get_settings)],
) -> None:
if supplied_key is None or not secrets.compare_digest(supplied_key, current_settings.api_key):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid or missing API key",
)
+34
View File
@@ -0,0 +1,34 @@
from functools import lru_cache
from pydantic import Field
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(
env_file=".env",
env_file_encoding="utf-8",
extra="ignore",
)
app_name: str = "Payment Processing Service"
api_key: str = Field(default="change-me", min_length=1)
database_url: str = "postgresql+asyncpg://payments:payments@localhost:5432/payments"
rabbitmq_url: str = "amqp://guest:guest@localhost:5672/"
outbox_poll_interval: float = Field(default=1.0, gt=0)
outbox_batch_size: int = Field(default=100, gt=0)
outbox_publish_timeout: float = Field(default=10.0, gt=0)
processing_min_seconds: float = Field(default=2.0, ge=0)
processing_max_seconds: float = Field(default=5.0, ge=0)
processing_success_rate: float = Field(default=0.9, ge=0, le=1)
processing_max_attempts: int = Field(default=3, ge=1)
retry_base_delay: float = Field(default=1.0, ge=0)
webhook_timeout: float = Field(default=10.0, gt=0)
@lru_cache
def get_settings() -> Settings:
return Settings()
settings = get_settings()
+40
View File
@@ -0,0 +1,40 @@
import logging
from faststream import FastStream
from faststream.exceptions import RejectMessage
from faststream.rabbit import RabbitBroker
from app.config import settings
from app.db import close_database, session_factory
from app.messaging import PAYMENTS_EXCHANGE, PAYMENTS_QUEUE, declare_topology
from app.processing import PaymentGateway, PaymentProcessor, WebhookClient
from app.schemas import PaymentCreatedEvent
logger = logging.getLogger(__name__)
broker = RabbitBroker(settings.rabbitmq_url, graceful_timeout=30.0)
app = FastStream(broker)
processor = PaymentProcessor(
session_factory,
PaymentGateway(settings),
WebhookClient(settings.webhook_timeout),
settings,
)
@broker.subscriber(PAYMENTS_QUEUE, PAYMENTS_EXCHANGE)
async def handle_payment(event: PaymentCreatedEvent) -> None:
try:
await processor.process(event)
except Exception as exc:
logger.exception("Payment %s exhausted all processing attempts", event.payment_id)
raise RejectMessage() from exc
@app.after_startup
async def setup_topology() -> None:
await declare_topology(broker)
@app.after_shutdown
async def shutdown_database() -> None:
await close_database()
+23
View File
@@ -0,0 +1,23 @@
from collections.abc import AsyncIterator
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.orm import DeclarativeBase
from app.config import settings
class Base(DeclarativeBase):
pass
engine = create_async_engine(settings.database_url, pool_pre_ping=True)
session_factory = async_sessionmaker(engine, expire_on_commit=False)
async def get_session() -> AsyncIterator[AsyncSession]:
async with session_factory() as session:
yield session
async def close_database() -> None:
await engine.dispose()
+57
View File
@@ -0,0 +1,57 @@
import asyncio
import logging
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from fastapi import FastAPI
from faststream.rabbit import Channel, RabbitBroker
from app.api import router
from app.config import settings
from app.db import close_database, session_factory
from app.messaging import declare_topology
from app.outbox import OutboxPublisher
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
)
@asynccontextmanager
async def lifespan(_: FastAPI) -> AsyncIterator[None]:
broker = RabbitBroker(
settings.rabbitmq_url,
default_channel=Channel(publisher_confirms=True, on_return_raises=True),
)
await broker.start()
await declare_topology(broker)
stop_event = asyncio.Event()
publisher = OutboxPublisher(
session_factory,
broker,
batch_size=settings.outbox_batch_size,
poll_interval=settings.outbox_poll_interval,
publish_timeout=settings.outbox_publish_timeout,
)
publisher_task = asyncio.create_task(publisher.run(stop_event))
try:
yield
finally:
stop_event.set()
try:
await asyncio.wait_for(
publisher_task,
timeout=settings.outbox_publish_timeout + settings.outbox_poll_interval,
)
except TimeoutError:
publisher_task.cancel()
await asyncio.gather(publisher_task, return_exceptions=True)
await broker.stop()
await close_database()
app = FastAPI(title=settings.app_name, version="1.0.0", lifespan=lifespan)
app.include_router(router)
+34
View File
@@ -0,0 +1,34 @@
from faststream.rabbit import ExchangeType, RabbitBroker, RabbitExchange, RabbitQueue
PAYMENTS_ROUTING_KEY = "payments.new"
DEAD_ROUTING_KEY = "payments.dead"
PAYMENTS_EXCHANGE = RabbitExchange(
"payments",
type=ExchangeType.DIRECT,
durable=True,
)
DEAD_EXCHANGE = RabbitExchange(
"payments.dlx",
type=ExchangeType.DIRECT,
durable=True,
)
PAYMENTS_QUEUE = RabbitQueue(
"payments.new",
durable=True,
arguments={
"x-dead-letter-exchange": DEAD_EXCHANGE.name,
"x-dead-letter-routing-key": DEAD_ROUTING_KEY,
},
)
DEAD_QUEUE = RabbitQueue("payments.dead", durable=True)
async def declare_topology(broker: RabbitBroker) -> None:
payments_exchange = await broker.declare_exchange(PAYMENTS_EXCHANGE)
dead_exchange = await broker.declare_exchange(DEAD_EXCHANGE)
payments_queue = await broker.declare_queue(PAYMENTS_QUEUE)
dead_queue = await broker.declare_queue(DEAD_QUEUE)
await payments_queue.bind(payments_exchange, routing_key=PAYMENTS_ROUTING_KEY)
await dead_queue.bind(dead_exchange, routing_key=DEAD_ROUTING_KEY)
+69
View File
@@ -0,0 +1,69 @@
import uuid
from datetime import datetime
from decimal import Decimal
from enum import StrEnum
from typing import Any
from sqlalchemy import DateTime, Enum, Integer, Numeric, String, Text, func
from sqlalchemy.dialects.postgresql import JSONB, UUID
from sqlalchemy.orm import Mapped, mapped_column
from app.db import Base
class Currency(StrEnum):
RUB = "RUB"
USD = "USD"
EUR = "EUR"
class PaymentStatus(StrEnum):
PENDING = "pending"
SUCCEEDED = "succeeded"
FAILED = "failed"
class Payment(Base):
__tablename__ = "payments"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
amount: Mapped[Decimal] = mapped_column(Numeric(18, 2), nullable=False)
currency: Mapped[Currency] = mapped_column(
Enum(Currency, name="currency", values_callable=lambda values: [v.value for v in values]),
nullable=False,
)
description: Mapped[str] = mapped_column(String(500), nullable=False)
metadata_: Mapped[dict[str, Any]] = mapped_column(
"metadata", JSONB, default=dict, nullable=False
)
status: Mapped[PaymentStatus] = mapped_column(
Enum(
PaymentStatus,
name="payment_status",
values_callable=lambda values: [v.value for v in values],
),
default=PaymentStatus.PENDING,
nullable=False,
)
idempotency_key: Mapped[str] = mapped_column(String(255), unique=True, nullable=False)
webhook_url: Mapped[str] = mapped_column(Text, nullable=False)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False
)
processed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
webhook_delivered_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
class OutboxEvent(Base):
__tablename__ = "outbox"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
aggregate_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), nullable=False, index=True)
event_type: Mapped[str] = mapped_column(String(100), nullable=False)
payload: Mapped[dict[str, Any]] = mapped_column(JSONB, nullable=False)
attempts: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
last_error: Mapped[str | None] = mapped_column(Text)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False
)
published_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), index=True)
+93
View File
@@ -0,0 +1,93 @@
import asyncio
import logging
from datetime import UTC, datetime
from faststream.rabbit import RabbitBroker
from sqlalchemy import select
from sqlalchemy.ext.asyncio import async_sessionmaker
from app.messaging import PAYMENTS_EXCHANGE, PAYMENTS_ROUTING_KEY
from app.models import OutboxEvent
logger = logging.getLogger(__name__)
class OutboxPublisher:
def __init__(
self,
session_factory: async_sessionmaker,
broker: RabbitBroker,
*,
batch_size: int,
poll_interval: float,
publish_timeout: float,
) -> None:
self._session_factory = session_factory
self._broker = broker
self._batch_size = batch_size
self._poll_interval = poll_interval
self._publish_timeout = publish_timeout
async def publish_batch(self, stop_event: asyncio.Event | None = None) -> int:
async with self._session_factory() as session:
event_ids = list(
await session.scalars(
select(OutboxEvent.id)
.where(OutboxEvent.published_at.is_(None))
.order_by(OutboxEvent.created_at)
.limit(self._batch_size)
)
)
published = 0
for event_id in event_ids:
if stop_event is not None and stop_event.is_set():
break
async with self._session_factory() as session:
event = await session.scalar(
select(OutboxEvent)
.where(
OutboxEvent.id == event_id,
OutboxEvent.published_at.is_(None),
)
.with_for_update(skip_locked=True)
)
if event is None:
continue
try:
await self._broker.publish(
event.payload,
queue=PAYMENTS_ROUTING_KEY,
exchange=PAYMENTS_EXCHANGE,
persist=True,
message_id=str(event.id),
message_type=event.event_type,
timeout=self._publish_timeout,
)
except Exception as exc:
event.attempts += 1
event.last_error = str(exc)[:2000]
logger.exception("Failed to publish outbox event %s", event.id)
else:
event.published_at = datetime.now(UTC)
event.attempts += 1
event.last_error = None
published += 1
await session.commit()
return published
async def run(self, stop_event: asyncio.Event) -> None:
while not stop_event.is_set():
try:
await self.publish_batch(stop_event)
except Exception:
logger.exception("Outbox polling iteration failed")
try:
await asyncio.wait_for(stop_event.wait(), timeout=self._poll_interval)
except TimeoutError:
pass
+49
View File
@@ -0,0 +1,49 @@
import uuid
from sqlalchemy import select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from app.models import OutboxEvent, Payment
from app.schemas import PaymentCreate, PaymentCreatedEvent
async def create_payment(
session: AsyncSession,
request: PaymentCreate,
idempotency_key: str,
) -> Payment:
payment_id = uuid.uuid4()
payment = Payment(
id=payment_id,
amount=request.amount,
currency=request.currency,
description=request.description,
metadata_=request.metadata,
idempotency_key=idempotency_key,
webhook_url=str(request.webhook_url),
)
event = PaymentCreatedEvent(payment_id=payment_id)
outbox_event = OutboxEvent(
aggregate_id=payment_id,
event_type="payment.created",
payload=event.model_dump(mode="json"),
)
session.add_all((payment, outbox_event))
try:
await session.commit()
except IntegrityError:
await session.rollback()
existing = await session.scalar(
select(Payment).where(Payment.idempotency_key == idempotency_key)
)
if existing is None:
raise
return existing
return payment
async def get_payment(session: AsyncSession, payment_id: uuid.UUID) -> Payment | None:
return await session.get(Payment, payment_id)
+129
View File
@@ -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()
+54
View File
@@ -0,0 +1,54 @@
import uuid
from datetime import datetime
from decimal import Decimal
from typing import Annotated, Any
from pydantic import BaseModel, ConfigDict, Field, HttpUrl
from app.models import Currency, PaymentStatus
Amount = Annotated[Decimal, Field(gt=0, max_digits=18, decimal_places=2)]
class PaymentCreate(BaseModel):
amount: Amount
currency: Currency
description: str = Field(min_length=1, max_length=500)
metadata: dict[str, Any] = Field(default_factory=dict)
webhook_url: HttpUrl
class PaymentAccepted(BaseModel):
model_config = ConfigDict(from_attributes=True)
payment_id: uuid.UUID = Field(validation_alias="id")
status: PaymentStatus
created_at: datetime
class PaymentDetails(BaseModel):
model_config = ConfigDict(from_attributes=True)
payment_id: uuid.UUID = Field(validation_alias="id")
amount: Decimal
currency: Currency
description: str
metadata: dict[str, Any] = Field(validation_alias="metadata_")
status: PaymentStatus
idempotency_key: str
webhook_url: str
created_at: datetime
processed_at: datetime | None
class PaymentCreatedEvent(BaseModel):
payment_id: uuid.UUID
class PaymentWebhook(BaseModel):
payment_id: uuid.UUID
amount: Decimal
currency: Currency
status: PaymentStatus
processed_at: datetime
metadata: dict[str, Any]