Refactor code structure for improved readability and maintainability
This commit is contained in:
@@ -0,0 +1,9 @@
|
||||
.git
|
||||
.env
|
||||
.venv
|
||||
__pycache__
|
||||
.pytest_cache
|
||||
.mypy_cache
|
||||
.ruff_cache
|
||||
graphify-out
|
||||
note.pdf
|
||||
@@ -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
@@ -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
@@ -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
|
||||
@@ -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
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
"""Asynchronous payment processing service."""
|
||||
+40
@@ -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
@@ -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",
|
||||
)
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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]
|
||||
@@ -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:
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user