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