Сервис записи на встречи. Клиент создаёт бронь через REST API — она сразу
сохраняется со статусом pending, а в очередь уходит фоновая задача. Воркер
подтверждает бронь (имитируя поход во внешний сервис, который иногда падает),
переводит её в confirmed или failed и пишет mock-уведомление в лог.
- FastAPI — асинхронный API с валидацией на Pydantic и Swagger из коробки.
- SQLAlchemy 2.0 + Alembic — работа с БД и миграции.
- PostgreSQL в проде, SQLite в тестах.
- Celery + Redis — фоновая обработка (Redis заодно и broker, и backend).
- structlog — логи в JSON.
- slowapi — лимит запросов на создание брони.
API сделан асинхронным (драйвер asyncpg), чтобы не блокировать event loop на
запросах к БД. А вот воркер синхронный: задачи Celery по своей природе синхронны,
тащить туда event loop смысла нет — проще и надёжнее взять psycopg2. Поэтому в
конфиге две строки подключения: DATABASE_URL для API и SYNC_DATABASE_URL для
воркера и Alembic.
| Метод | Путь | Что делает |
|---|---|---|
| POST | /bookings |
создать бронь (name, datetime, service_type) |
| GET | /bookings/{id} |
посмотреть статус |
| GET | /bookings |
список, фильтр ?status=, пагинация ?limit=&offset= |
| DELETE | /bookings/{id} |
отменить — только пока бронь pending |
service_type: consultation, meeting, support.
status: pending → confirmed или failed.
Когда сервис запущен, есть интерактивная документация — там же можно дёргать эндпоинты прямо из браузера:
- Swagger UI — http://localhost:8000/docs
- ReDoc — http://localhost:8000/redoc
- сырая схема OpenAPI — http://localhost:8000/openapi.json
Одна таблица bookings:
| Поле | Тип | Заметки |
|---|---|---|
id |
VARCHAR(36) |
UUID, первичный ключ |
name |
VARCHAR(255) |
имя |
datetime |
TIMESTAMPTZ |
время встречи |
service_type |
VARCHAR(32) |
consultation / meeting / support |
status |
VARCHAR(16) |
pending / confirmed / failed, есть индекс |
created_at, updated_at |
TIMESTAMPTZ |
проставляются БД |
На status повешен индекс (ix_bookings_status) — это единственное поле, по
которому идёт фильтрация в GET /bookings?status=..., и список по статусу
хочется отдавать без полного скана таблицы.
cp .env.example .env
docker-compose up --buildПоднимутся Postgres, Redis, API и воркер. Миграции накатываются сами при старте
API. Дальше API живёт на http://localhost:8000, документация — на /docs.
Создать бронь — сразу возвращается pending, обработка уходит в очередь:
curl -X POST localhost:8000/bookings \
-H 'Content-Type: application/json' \
-d '{"name":"Pavel","datetime":"2026-07-01T10:00:00Z","service_type":"meeting"}'{
"id": "da7693e0-c330-4596-8e50-4e4ba7bd1df7",
"name": "Pavel",
"datetime": "2026-07-01T10:00:00Z",
"service_type": "meeting",
"status": "pending",
"created_at": "2026-06-17T14:19:11.752391Z",
"updated_at": "2026-06-17T14:19:11.752391Z"
}Невалидное тело — 422 с понятной ошибкой:
curl -X POST localhost:8000/bookings \
-H 'Content-Type: application/json' \
-d '{"name":"Pavel","datetime":"2026-07-01T10:00:00Z","service_type":"spaceship"}'{
"detail": [
{
"type": "enum",
"loc": ["body", "service_type"],
"msg": "Input should be 'consultation', 'meeting' or 'support'",
"input": "spaceship"
}
]
}Статус брони — через секунду-другую воркер переводит её в confirmed
(или в failed с шансом ~15%):
curl localhost:8000/bookings/da7693e0-c330-4596-8e50-4e4ba7bd1df7{
"id": "da7693e0-c330-4596-8e50-4e4ba7bd1df7",
"name": "Pavel",
"datetime": "2026-07-01T10:00:00Z",
"service_type": "meeting",
"status": "confirmed",
"created_at": "2026-06-17T14:19:11.752391Z",
"updated_at": "2026-06-17T14:19:11.860709Z"
}Несуществующий id — 404:
{ "detail": "Booking not found" }Список с фильтром по статусу и пагинацией:
curl "localhost:8000/bookings?status=confirmed&limit=20&offset=0"{
"items": [
{
"id": "da7693e0-c330-4596-8e50-4e4ba7bd1df7",
"name": "Pavel",
"datetime": "2026-07-01T10:00:00Z",
"service_type": "meeting",
"status": "confirmed",
"created_at": "2026-06-17T14:19:11.752391Z",
"updated_at": "2026-06-17T14:19:11.860709Z"
}
],
"total": 1,
"limit": 20,
"offset": 0
}Отмена — работает только пока бронь pending (тогда 204 без тела). Если
бронь уже confirmed/failed — 409:
curl -X DELETE localhost:8000/bookings/da7693e0-c330-4596-8e50-4e4ba7bd1df7{ "detail": "Only pending bookings can be cancelled" }В логах воркера на успешной брони видно mock-уведомление:
{"booking_id": "da7693e0-...", "channel": "mock", "event": "notification_sent", "level": "info", "timestamp": "2026-06-17T14:19:11.860Z"}Docker для них не нужен — всё на SQLite in-memory и Celery в eager-режиме:
python3 -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt
pytestПокрыты все эндпоинты (включая 422/404/409, фильтр и пагинацию) и логика воркера:
успех, провал, идемпотентность, отсутствующая бронь. Или просто make test.
Идемпотентность. Первое, что делает задача — читает бронь и смотрит на статус.
Если брони уже нет или она не pending, задача молча выходит. За счёт этого
повторная доставка из очереди или ретрай с тем же booking_id ничего не ломают и
не шлют второе уведомление — оно уходит ровно один раз, на переходе
pending → confirmed.
Задача ставится после коммита. Сначала бронь фиксируется в БД, и только потом уходит в очередь. Иначе воркер мог бы схватить задачу раньше, чем строка появилась в базе.
Сбои и ретраи. Внешний вызов с вероятностью FAILURE_RATE бросает исключение.
На ошибке задача переотправляется с экспоненциальным backoff, а в failed
переходит только когда MAX_RETRIES исчерпан — разовый сбой не должен хоронить
бронь навсегда.
UUID как id. Не светим автоинкремент наружу, удобно для идемпотентности. Храним строкой на 36 символов, чтобы одинаково работало и в Postgres, и в SQLite.
Раздельные БД в тестах. API и воркер тестируются на своих in-memory базах: гонять одно in-memory-соединение разом через async- и sync-движок — путь к плавающим багам. Внешний вызов в тестах всегда замокан, на рандом не полагаемся.
app/
main.py FastAPI: роутеры, rate-limit, lifespan
config.py настройки (pydantic-settings)
database.py async-сессия для API
models.py модель Booking, статусы, типы услуг
schemas.py Pydantic-схемы
crud.py операции с БД
logging_config.py structlog → JSON
rate_limit.py лимитер для POST
api/
bookings.py эндпоинты
dispatch.py постановка задачи (мокается в тестах)
worker/
celery_app.py инстанс Celery
db.py sync-сессия
external.py мок внешнего сервиса
tasks.py process_booking
alembic/ миграции
docker/entrypoint.sh миграции + запуск API/воркера
tests/ pytest
docs/img/ скриншоты для README
docker-compose.yml db + redis + api + worker
Шаблон — в .env.example:
| Переменная | Зачем |
|---|---|
DATABASE_URL |
подключение API (async) |
SYNC_DATABASE_URL |
подключение воркера и Alembic (sync) |
CELERY_BROKER_URL / CELERY_RESULT_BACKEND |
Redis для Celery |
FAILURE_RATE |
вероятность сбоя внешнего сервиса (0..1) |
RATE_LIMIT |
лимит на POST (синтаксис slowapi) |
MAX_RETRIES |
сколько раз ретраить упавшую задачу |
LOG_LEVEL |
уровень логов |
