2026, Jan 12 12:05

Идемпотентный POST во Flask с PostgreSQL: ON CONFLICT RETURNING для 201/200

Идемпотентный POST во Flask с PostgreSQL: UNIQUE и ON CONFLICT DO NOTHING RETURNING, чтобы вернуть 201 при создании и 200 при повторах без дубликатов и гонок.

Когда мобильный клиент повторяет POST из‑за нестабильной сети, ваш аккуратный однострочный insert может тихо размножить строки. Для эндпоинта загрузки, который пишет в PostgreSQL, это означает дубликаты, зашумленную аналитику и последующую ручную чистку. Задача проста: сделать маршрут идемпотентным, вернуть 201 Created при первом запросе, 200 OK при корректных повторах — и при этом оставить решение компактным и идиоматичным.

Воспроизводим проблему

Следующий маршрут принимает JSON и вставляет запись. Он работает, но повторные POST с одним и тем же телом создают дубликаты.

from flask import Flask, request, jsonify

svc = Flask(__name__)

@svc.post("/upload")
def push_photo():
    data = request.get_json()
    dbh.execute(
        "INSERT INTO photos (user_id, filename, uploaded_at) VALUES (%s, %s, NOW())",
        (data["user_id"], data["filename"]),
    )
    return jsonify({"status": "ok"}), 201

Что на самом деле происходит

Иногда клиенты повторяют POST /upload, когда сеть дает сбои. Без гарантии уникальности в базе каждый повтор — это новая вставка, и дубликаты накапливаются. Одно лишь ограничение уникальности (UNIQUE) предотвращает дубликат, но выдает клиенту «сырую» ошибку SQL. Обертывание вставки в ON CONFLICT DO NOTHING скрывает ошибку, однако обработчик больше не различает «создано» и «уже существовало», поэтому невозможно вернуть 201 в первый раз и 200 при повторе.

Минимальное и идиоматичное решение

Самый простой и надежный подход — обеспечить уникальность в таблице и использовать upsert, который сообщает, произошло ли добавление. В PostgreSQL это удобно делается через INSERT … ON CONFLICT … DO NOTHING RETURNING. Уникальный ключ не допускает дубликатов; предложение RETURNING говорит, была ли создана новая строка; а код статуса напрямую следует из этого сигнала.

Сначала убедитесь, что интересующая вас пара уникальна на уровне базы данных, чтобы дубликаты физически не могли появиться:

ALTER TABLE photos
ADD CONSTRAINT photos_user_filename_key UNIQUE (user_id, filename);

Затем используйте одиночный INSERT с ON CONFLICT DO NOTHING RETURNING, чтобы определить, удалось ли вставить запись. Если оператор вернул строку — это новая вставка, возвращайте 201. Если не вернул ничего — строка уже существовала, возвращайте 200.

Итоговый маршрут

from flask import Flask, request, jsonify

svc = Flask(__name__)

@svc.post("/upload")
def push_photo():
    body = request.get_json()

    sql = (
        "INSERT INTO photos (user_id, filename, uploaded_at) "
        "VALUES (%s, %s, NOW()) "
        "ON CONFLICT (user_id, filename) DO NOTHING "
        "RETURNING 1"
    )

    result = dbh.execute(sql, (body["user_id"], body["filename"]))
    created = result.fetchone() is not None

    status_code = 201 if created else 200
    return jsonify({"status": "ok"}), status_code

Почему это важно

Этот паттерн держит идемпотентность там, где ей и место: на границе между приложением и базой данных. Ограничение уникальности гарантирует корректность при повторах и гонках. Upsert с RETURNING дает точный сигнал «создано» против «уже было» без дополнительной инфраструктуры. Отображение этого сигнала на HTTP‑семантику делает API предсказуемым для клиентов. Можно было бы перехватывать ошибку базы и возвращать 200 при дубликатах, но опора на чистый путь через RETURNING прямее и избавляет от разборов ошибок.

Итоги

Для идемпотентных POST‑загрузок во Flask с PostgreSQL опирайтесь на базу данных. Задайте UNIQUE для естественного идентификатора, используйте INSERT … ON CONFLICT … DO NOTHING RETURNING, чтобы узнать, создана ли строка, и переводите это в 201 в первый раз и 200 при повторах. В итоге получаем небольшой, надежный эндпоинт, который устойчив к сетевым повторам без добавления очередей, кэшей или внешних сервисов.