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
+9
View File
@@ -0,0 +1,9 @@
.git
.env
.venv
__pycache__
.pytest_cache
.mypy_cache
.ruff_cache
graphify-out
note.pdf
+12
View File
@@ -0,0 +1,12 @@
API_KEY=local-secret
DATABASE_URL=postgresql+asyncpg://payments:payments@postgres:5432/payments
RABBITMQ_URL=amqp://guest:guest@rabbitmq:5672/
OUTBOX_POLL_INTERVAL=1
OUTBOX_BATCH_SIZE=100
OUTBOX_PUBLISH_TIMEOUT=10
PROCESSING_MIN_SECONDS=2
PROCESSING_MAX_SECONDS=5
PROCESSING_SUCCESS_RATE=0.9
PROCESSING_MAX_ATTEMPTS=3
RETRY_BASE_DELAY=1
WEBHOOK_TIMEOUT=10
+14
View File
@@ -0,0 +1,14 @@
.env
.pytest_cache/
.mypy_cache/
.ruff_cache/
.serena/
__pycache__/
*.py[cod]
.coverage
htmlcov/
.venv/
dist/
build/
*.egg-info/
graphify-out/
+15
View File
@@ -0,0 +1,15 @@
FROM python:3.12-slim
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1
WORKDIR /service
COPY pyproject.toml ./
COPY app ./app
COPY alembic.ini ./
COPY migrations ./migrations
RUN pip install --no-cache-dir .
USER 65534:65534
+100
View File
@@ -0,0 +1,100 @@
# Асинхронный сервис процессинга платежей
Микросервис принимает платежи по HTTP, атомарно сохраняет платёж и outbox-событие в PostgreSQL, публикует событие в RabbitMQ и асинхронно отправляет результат на webhook.
## Запуск
Требования: Docker с Compose plugin.
```bash
docker compose up --build
```
После запуска доступны:
- API и Swagger UI: <http://localhost:8000/docs>
- RabbitMQ Management: <http://localhost:15672> (`guest` / `guest`)
- API key по умолчанию: `local-secret`; для другого значения задайте `API_KEY`
Миграции выполняются контейнером `migrate` до запуска API и consumer.
## API
Создать платёж:
```bash
curl -i -X POST http://localhost:8000/api/v1/payments \
-H 'Content-Type: application/json' \
-H 'X-API-Key: local-secret' \
-H 'Idempotency-Key: order-123-attempt-1' \
-d '{
"amount": "1250.50",
"currency": "RUB",
"description": "Заказ 123",
"metadata": {"order_id": 123},
"webhook_url": "https://example.com/payment-webhook"
}'
```
Ответ имеет статус `202 Accepted`:
```json
{
"payment_id": "8f814d58-8a52-493f-aafb-98b383aa0325",
"status": "pending",
"created_at": "2026-09-16T08:00:00Z"
}
```
Повторный запрос с тем же `Idempotency-Key` вернёт тот же платёж и не создаст новое outbox-событие.
Получить платёж:
```bash
curl http://localhost:8000/api/v1/payments/8f814d58-8a52-493f-aafb-98b383aa0325 \
-H 'X-API-Key: local-secret'
```
Webhook получает JSON следующего вида:
```json
{
"payment_id": "8f814d58-8a52-493f-aafb-98b383aa0325",
"amount": "1250.50",
"currency": "RUB",
"status": "succeeded",
"processed_at": "2026-09-16T08:00:04Z",
"metadata": {"order_id": 123}
}
```
## Надёжность
- `payments` и `outbox` записываются в одной транзакции.
- Publisher читает outbox через `FOR UPDATE SKIP LOCKED` и помечает событие после publisher confirm RabbitMQ.
- Доставка события имеет семантику at-least-once. Consumer идемпотентен по состоянию платежа.
- Обработка и webhook выполняются до трёх раз с задержками 1 и 2 секунды.
- После исчерпания попыток сообщение отклоняется без requeue и RabbitMQ переносит его через `payments.dlx` в `payments.dead`.
- Если процесс остановился после обновления статуса, но до webhook, повторная доставка продолжит только webhook.
## Локальная разработка
```bash
python -m venv .venv
. .venv/bin/activate
pip install -e '.[dev]'
cp .env.example .env
ruff check .
ty check
pytest
```
Для локального запуска без Docker измените в `.env` адреса PostgreSQL и RabbitMQ на `localhost`, затем выполните:
```bash
alembic upgrade head
uvicorn app.main:app --reload
faststream run app.consumer:app
```
Основные параметры эмуляции (`PROCESSING_*`), retry и polling outbox перечислены в `.env.example`.
+38
View File
@@ -0,0 +1,38 @@
[alembic]
script_location = migrations
prepend_sys_path = .
sqlalchemy.url = postgresql+asyncpg://payments:payments@localhost:5432/payments
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S
+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]
+63
View File
@@ -0,0 +1,63 @@
services:
postgres:
image: postgres:16-alpine
environment:
POSTGRES_DB: payments
POSTGRES_USER: payments
POSTGRES_PASSWORD: payments
healthcheck:
test: ["CMD-SHELL", "pg_isready -U payments -d payments"]
interval: 5s
timeout: 5s
retries: 10
volumes:
- postgres_data:/var/lib/postgresql/data
rabbitmq:
image: rabbitmq:3.13-management-alpine
ports:
- "15672:15672"
healthcheck:
test: ["CMD", "rabbitmq-diagnostics", "-q", "ping"]
interval: 5s
timeout: 5s
retries: 10
volumes:
- rabbitmq_data:/var/lib/rabbitmq
migrate:
build: .
command: ["alembic", "upgrade", "head"]
environment: &service_environment
API_KEY: ${API_KEY:-local-secret}
DATABASE_URL: postgresql+asyncpg://payments:payments@postgres:5432/payments
RABBITMQ_URL: amqp://guest:guest@rabbitmq:5672/
depends_on:
postgres:
condition: service_healthy
api:
build: .
command: ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
environment: *service_environment
ports:
- "8000:8000"
depends_on:
migrate:
condition: service_completed_successfully
rabbitmq:
condition: service_healthy
consumer:
build: .
command: ["faststream", "run", "app.consumer:app"]
environment: *service_environment
depends_on:
migrate:
condition: service_completed_successfully
rabbitmq:
condition: service_healthy
volumes:
postgres_data:
rabbitmq_data:
+61
View File
@@ -0,0 +1,61 @@
import asyncio
from logging.config import fileConfig
from alembic import context
from sqlalchemy import Connection, pool
from sqlalchemy.ext.asyncio import async_engine_from_config
from app import models # noqa: F401
from app.config import settings
from app.db import Base
config = context.config
config.set_main_option("sqlalchemy.url", settings.database_url)
if config.config_file_name is not None:
fileConfig(config.config_file_name)
target_metadata = Base.metadata
def run_migrations_offline() -> None:
context.configure(
url=config.get_main_option("sqlalchemy.url"),
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
compare_type=True,
)
with context.begin_transaction():
context.run_migrations()
def do_run_migrations(connection: Connection) -> None:
context.configure(connection=connection, target_metadata=target_metadata, compare_type=True)
with context.begin_transaction():
context.run_migrations()
async def run_async_migrations() -> None:
connectable = async_engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
async with connectable.connect() as connection:
await connection.run_sync(do_run_migrations)
await connectable.dispose()
def run_migrations_online() -> None:
asyncio.run(run_async_migrations())
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()
+24
View File
@@ -0,0 +1,24 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from collections.abc import Sequence
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
revision: str = ${repr(up_revision)}
down_revision: str | None = ${repr(down_revision)}
branch_labels: str | Sequence[str] | None = ${repr(branch_labels)}
depends_on: str | Sequence[str] | None = ${repr(depends_on)}
def upgrade() -> None:
${upgrades if upgrades else "pass"}
def downgrade() -> None:
${downgrades if downgrades else "pass"}
@@ -0,0 +1,76 @@
"""Create payments and outbox tables.
Revision ID: 20260916_0001
Revises:
Create Date: 2026-09-16 08:00:00
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects import postgresql
revision: str = "20260916_0001"
down_revision: str | None = None
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
currency = postgresql.ENUM("RUB", "USD", "EUR", name="currency", create_type=False)
payment_status = postgresql.ENUM(
"pending", "succeeded", "failed", name="payment_status", create_type=False
)
currency.create(op.get_bind(), checkfirst=True)
payment_status.create(op.get_bind(), checkfirst=True)
op.create_table(
"payments",
sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("amount", sa.Numeric(precision=18, scale=2), nullable=False),
sa.Column("currency", currency, nullable=False),
sa.Column("description", sa.String(length=500), nullable=False),
sa.Column("metadata", postgresql.JSONB(astext_type=sa.Text()), nullable=False),
sa.Column("status", payment_status, nullable=False),
sa.Column("idempotency_key", sa.String(length=255), nullable=False),
sa.Column("webhook_url", sa.Text(), nullable=False),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.Column("processed_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("webhook_delivered_at", sa.DateTime(timezone=True), nullable=True),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("idempotency_key"),
)
op.create_table(
"outbox",
sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("aggregate_id", postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("event_type", sa.String(length=100), nullable=False),
sa.Column("payload", postgresql.JSONB(astext_type=sa.Text()), nullable=False),
sa.Column("attempts", sa.Integer(), nullable=False),
sa.Column("last_error", sa.Text(), nullable=True),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.Column("published_at", sa.DateTime(timezone=True), nullable=True),
sa.PrimaryKeyConstraint("id"),
)
op.create_index("ix_outbox_aggregate_id", "outbox", ["aggregate_id"])
op.create_index("ix_outbox_published_at", "outbox", ["published_at"])
def downgrade() -> None:
op.drop_index("ix_outbox_published_at", table_name="outbox")
op.drop_index("ix_outbox_aggregate_id", table_name="outbox")
op.drop_table("outbox")
op.drop_table("payments")
postgresql.ENUM(name="payment_status").drop(op.get_bind(), checkfirst=True)
postgresql.ENUM(name="currency").drop(op.get_bind(), checkfirst=True)
+45
View File
@@ -0,0 +1,45 @@
[build-system]
requires = ["setuptools>=75"]
build-backend = "setuptools.build_meta"
[project]
name = "payment-processing-service"
version = "0.1.0"
description = "Asynchronous payment processing service"
requires-python = ">=3.12"
dependencies = [
"alembic>=1.14,<2",
"asyncpg>=0.30,<1",
"fastapi>=0.115,<1",
"faststream[cli,rabbit]>=0.5.48,<1",
"httpx>=0.28,<1",
"pydantic>=2.10,<3",
"pydantic-settings>=2.7,<3",
"sqlalchemy[asyncio]>=2.0.36,<3",
"uvicorn[standard]>=0.34,<1",
]
[project.optional-dependencies]
dev = [
"pytest>=8.3,<9",
"pytest-asyncio>=0.25,<1",
"ruff>=0.9,<1",
"ty>=0.0.18,<0.1",
]
[tool.setuptools.packages.find]
include = ["app*"]
[tool.pytest.ini_options]
asyncio_mode = "auto"
testpaths = ["tests"]
[tool.ruff]
line-length = 100
target-version = "py312"
[tool.ruff.lint]
select = ["E", "F", "I", "UP", "B", "ASYNC", "RUF"]
[tool.ty.environment]
python-version = "3.12"
+58
View File
@@ -0,0 +1,58 @@
import uuid
from fastapi import FastAPI
from fastapi.testclient import TestClient
from app.api import router
def make_client() -> TestClient:
app = FastAPI()
app.include_router(router)
return TestClient(app)
def test_get_payment_requires_api_key() -> None:
with make_client() as client:
response = client.get(f"/api/v1/payments/{uuid.uuid4()}")
assert response.status_code == 401
assert response.json() == {"detail": "Invalid or missing API key"}
def test_create_payment_requires_idempotency_key() -> None:
with make_client() as client:
response = client.post(
"/api/v1/payments",
headers={"X-API-Key": "change-me"},
json={
"amount": "100.00",
"currency": "RUB",
"description": "Test payment",
"metadata": {},
"webhook_url": "https://example.com/webhook",
},
)
assert response.status_code == 422
assert response.json()["detail"][0]["loc"] == ["header", "Idempotency-Key"]
def test_create_payment_rejects_unsupported_currency() -> None:
with make_client() as client:
response = client.post(
"/api/v1/payments",
headers={
"X-API-Key": "change-me",
"Idempotency-Key": "test-key",
},
json={
"amount": "100.00",
"currency": "GBP",
"description": "Test payment",
"metadata": {},
"webhook_url": "https://example.com/webhook",
},
)
assert response.status_code == 422
+53
View File
@@ -0,0 +1,53 @@
import pytest
from app.processing import retry_async
@pytest.mark.asyncio
async def test_retry_uses_exponential_delay_and_returns_success() -> None:
calls = 0
delays: list[float] = []
async def operation() -> str:
nonlocal calls
calls += 1
if calls < 3:
raise RuntimeError("temporary failure")
return "processed"
async def record_sleep(delay: float) -> None:
delays.append(delay)
result = await retry_async(
operation,
attempts=3,
base_delay=1,
sleep=record_sleep,
)
assert result == "processed"
assert calls == 3
assert delays == [1, 2]
@pytest.mark.asyncio
async def test_retry_raises_last_error_after_three_attempts() -> None:
calls = 0
async def operation() -> None:
nonlocal calls
calls += 1
raise RuntimeError(f"failure {calls}")
async def no_sleep(_: float) -> None:
pass
with pytest.raises(RuntimeError, match="failure 3"):
await retry_async(
operation,
attempts=3,
base_delay=1,
sleep=no_sleep,
)
assert calls == 3
Generated
+1507
View File
File diff suppressed because it is too large Load Diff