Skip to content

Repository files navigation

Payments Service

Репозиторий: https://github.com/Andy27-git/payments-service

Асинхронный сервис процессинга платежей: принимает запрос на оплату, публикует событие в RabbitMQ через outbox pattern, обрабатывает его consumer'ом (эмуляция платёжного шлюза) и уведомляет клиента через webhook — с повторными попытками и Dead Letter Queue при окончательном сбое доставки.

Стек

FastAPI + Pydantic v2 · SQLAlchemy 2.0 (async) · PostgreSQL · RabbitMQ (FastStream) · Alembic · Docker / docker-compose

Запуск

git clone https://github.com/Andy27-git/payments-service.git && cd payments-service
cp .env.example .env
docker compose up --build

Поднимаются 4 сервиса: postgres, rabbitmq, api (гоняет alembic upgrade head, затем uvicorn), consumer (FastStream-приложение с обработчиком payments.new и фоновой outbox-relay задачей). Порядок старта: postgres + rabbitmq (healthy) → api (миграции + healthy) → consumer.

  • API: http://localhost:8000
  • RabbitMQ management UI: http://localhost:15672 (guest/guest)

API

Все эндпоинты, включая GET /health, требуют заголовок X-API-Key (значение — API_KEY из .env). Docker healthcheck передаёт этот же ключ из окружения контейнера.

POST /api/v1/payments

Обязательный заголовок Idempotency-Key.

curl -X POST http://localhost:8000/api/v1/payments \
  -H "X-API-Key: change-me" \
  -H "Idempotency-Key: order-42" \
  -H "Content-Type: application/json" \
  -d '{
        "amount": "10.00",
        "currency": "RUB",
        "description": "тестовый платёж",
        "metadata": {"order_id": "42"},
        "webhook_url": "https://webhook.site/<your-id>"
      }'
# 202 Accepted
# {"payment_id": "...", "status": "pending", "created_at": "..."}
  • Повтор того же запроса с тем же Idempotency-Key и тем же телом → тот же payment_id, без дублирования в БД (тоже 202).
  • Тот же Idempotency-Key, но другое тело → 409 Conflict.
  • Без Idempotency-Key422 (объявлен как обязательный заголовок — FastAPI отклоняет запрос на этапе валидации параметров, до вызова какой-либо бизнес-логики).
  • Без X-API-Key, либо заголовок присутствует, но неверный (в том числе с не-ASCII символами) → 401 — это ошибка аутентификации, а не валидации запроса.
  • Некорректное тело (amount <= 0, неизвестная валюта, невалидный webhook_url) → 422.

GET /api/v1/payments/{payment_id}

curl http://localhost:8000/api/v1/payments/<payment_id> -H "X-API-Key: change-me"

Возвращает полную информацию о платеже, включая status, webhook_attempts, webhook_delivered_at, processed_at. Неизвестный payment_id404.

Через несколько секунд после создания (эмуляция шлюза занимает 2-5с) status меняется на succeeded или failed, processed_at заполняется, а если webhook_url был рабочим — заполняется и webhook_delivered_at:

{
  "payment_id": "...", "amount": "10.00", "currency": "RUB",
  "description": "тестовый платёж", "metadata": {"order_id": "42"},
  "status": "succeeded", "idempotency_key": "order-42",
  "webhook_url": "https://webhook.site/<your-id>",
  "webhook_delivered_at": "2026-...", "webhook_attempts": 1,
  "created_at": "2026-...", "processed_at": "2026-..."
}

На webhook_url при этом приходит POST с телом {"payment_id": "...", "status": "succeeded", "amount": "10.00", "currency": "RUB"}.

Валюта — одна из RUB, USD, EUR.

Поток обработки

POST /api/v1/payments
        │  одна транзакция: INSERT payments(status=pending) + INSERT outbox
        ▼
   таблица outbox
        │  outbox relay (фоновая задача в consumer, опрос ~0.5с,
        │  SELECT ... FOR UPDATE SKIP LOCKED)
        ▼
   RabbitMQ: exchange "payments" → queue payments.new
        │
        ▼
   consumer: handle_payment_new  ◄────────────────────────────┐
        │                                                      │
        ├─ status == pending? ─── да ──► gateway-эмулятор       │
        │                               (2-5с, 90% succeeded /  │
        │                                10% failed)            │
        │                                     │                 │
        │                                     ▼                 │
        └─ status уже выставлен ───────► webhook/sender.py       │ TTL истёк →
          (повторная доставка,           (1 попытка)             │ назад в payments.new
           см. "Идемпотентность                                  │ (x-dead-letter-*)
           обработчика" ниже)                 │                  │
                                  ┌─────────────┴──────────────┐  │
                                  ▼                             ▼  │
                          2xx: webhook_delivered_at    техническая ошибка
                          проставлен, сообщение        (5xx/timeout/БД недоступна)
                          ack'ается                             │
                                                    x-retry-count < 3?
                                                     да │           │ нет
                                             publish в  │           │ publish в payments.dlx
                                    payments.new.retry.{2s|4s|8s}   ▼
                                                                queue payments.new.dlq

Исходное сообщение из payments.new всегда ack'ается — повтор организован не через nack/requeue брокера, а явной публикацией в отдельные retry-очереди с x-message-ttl (2000/4000/8000 мс) и x-dead-letter-exchange: payments, x-dead-letter-routing-key: payments.new: по истечении TTL RabbitMQ сам перекладывает сообщение обратно в payments.new.

Бизнес-провал ≠ технический сбой

10% «ошибок» от gateway-эмулятора — это терминальный результат обработки (status=failed): webhook с этим результатом отправляется нормально, в retry/DLQ сообщение не уходит. Retry-механизм предназначен только для сбоев доставки — недоступной БД, таймаута или 5xx от webhook_url. Смешивать эти два случая означало бы либо ретраить платежи, которые уже честно завершились неуспехом, либо никогда не уведомлять клиента об инфраструктурных сбоях.

Идемпотентность обработчика

Сообщение может быть доставлено повторно (redelivery из retry-очереди после неудачного вебхука, дубль от at-least-once publish в outbox relay). Обработчик выходит без действий, только если платёж и терминален (status != pending), и вебхук уже доставлен (webhook_delivered_at is not None). Проверка одного статуса была бы ошибочной: сообщение, вернувшееся из retry-очереди именно из-за неудачного вебхука, увидело бы уже проставленный status и вышло бы, так и не переотправив вебхук.

Почему outbox relay живёт в процессе consumer'а

По ТЗ в docker-compose ровно 4 сервиса: postgres, rabbitmq, api, consumer. Отдельного relay-сервиса нет — outbox relay запускается как фоновая asyncio-задача внутри consumer, а не внутри api: тогда падение/перезапуск api не тормозит публикацию накопленных событий.

Тот же цикл раз в 10 минут удаляет уже опубликованные outbox-записи старше OUTBOX_RETENTION_HOURS — иначе таблица растёт бесконечно. Неопубликованные строки уборка не трогает никогда, так что гарантия доставки от неё не зависит.

RabbitMQ топология

Все exchange и очереди — durable=True, все сообщения публикуются persistent (delivery_mode=2), иначе рестарт контейнера rabbitmq терял бы всё, что outbox pattern уже гарантированно доставил в брокер.

Объект Тип Комментарий
payments exchange (direct) основной поток событий
payments.new queue обрабатывается consumer'ом; x-dead-letter-*payments.new.dlq как страховка (см. ниже)
payments.new.retry.2s / .4s / .8s queue x-message-ttl + dead-letter обратно в payments.new
payments.dlx exchange (direct) публикуется явно из consumer'а после 3 неудачных попыток
payments.new.dlq queue конечная точка для сообщений, не обработанных после 3 попыток

Три отдельные retry-очереди вместо одной с per-message expiration — потому что RabbitMQ проверяет истечение TTL только у сообщения в голове очереди: с общим backoff 2/4/8с сообщение с меньшим TTL ждало бы позади сообщения с большим TTL столько же, сколько и оно (head-of-line blocking).

Аргументы очередей (x-message-ttl, x-dead-letter-*) в RabbitMQ неизменяемы: если очередь уже создана с другим набором аргументов, повторное объявление падает с PRECONDITION_FAILED. declare_topology перехватывает это и сообщает, что делать (docker compose down -v), вместо того чтобы отдать наружу трейсбек aiormq. Это касается только апгрейда поверх старого тома rabbitmq_data — на чистом окружении такого не бывает.

x-dead-letter-* на самой payments.new — это страховка, а не основной путь: штатные сбои consumer публикует в retry-очереди сам. Но если сообщение будет reject'нуто мимо нашего try (тело не разбирается в dict ещё до вызова хендлера, либо не удался сам publish в retry-очередь), без DLX оно бы бесследно исчезло — а с ним попадёт в payments.new.dlq и останется видимым.

Тесты

uv sync
uv run pytest              # unit + integration, sqlite in-memory, без Docker
uv run pytest -m pg         # + один тест на реальном Postgres (testcontainers)

SELECT ... FOR UPDATE SKIP LOCKED в sqlite не поддерживается по-настоящему (конструкция не отклоняется, но и не даёт нужной семантики пропуска залоченных строк) — как и нативный JSONB, и alembic-миграции против sqlite не прогонялись. Поэтому конкурентное поведение outbox relay (двух relay-корутин, разбирающих одни и те же строки без дублей) проверяется отдельным тестом с меткой @pytest.mark.pg: поднимает Postgres через testcontainers, реально прогоняет alembic upgrade head и только затем гоняет relay. Требует Docker; если он недоступен — или запущен, но контейнер недостижим с хоста (проброс портов, firewall) — тест пропускается, а не падает.

Переменные окружения

См. .env.example:

Переменная Назначение
DATABASE_URL postgresql+asyncpg://...
RABBITMQ_URL amqp://...
API_KEY статический ключ для заголовка X-API-Key
LOG_LEVEL уровень логирования (JSON-логи в stdout)
OUTBOX_RETENTION_HOURS сколько часов хранить опубликованные outbox-записи (по умолчанию 24, 0 отключает уборку)

Проверка retry/DLQ вручную

  1. Создать платёж с заведомо нерабочим webhook_url (например http://127.0.0.1:1/hook).
  2. В RabbitMQ management UI (http://localhost:15672, очереди vhost /) видно, как сообщение проходит payments.new.retry.2s.4s.8s.
  3. После третьей неудачной повторной попытки (то есть после четвёртой отправки вебхука в целом: исходная + 3 ретрая) сообщение оказывается в payments.new.dlq.
  4. GET /api/v1/payments/{id}webhook_attempts = 4, webhook_delivered_at = null.

Проверка durability вручную

  1. Создать несколько платежей, не дожидаясь их обработки (или временно остановить consumer: docker compose stop consumer), чтобы в payments.new накопились сообщения.
  2. docker compose restart rabbitmq.
  3. После рестарта сообщения по-прежнему в очереди (видно в management UI) — это следствие durable=True на очередях и persist=True при публикации (delivery_mode=2). Без этого рестарт брокера обесценивал бы outbox pattern: события были бы гарантированно доставлены из БД в брокер и тут же потеряны.

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages