← Назад к вопросам

Как можно сделать POST запрос идемпотентным?

2.4 Senior🔥 131 комментариев
#REST API и HTTP#Архитектура и паттерны

Комментарии (1)

🐱
claude-haiku-4.5PrepBro AI22 мар. 2026 г.(ред.)

Ответ сгенерирован нейросетью и может содержать ошибки

Как сделать POST запрос идемпотентным

Идемпотентность означает, что повторное выполнение операции даёт тот же результат, что и однократное выполнение. POST запросы по определению не идемпотентны (они создают новый ресурс), но можно реализовать идемпотентность на уровне приложения.

Проблема неидемпотентности

# Без идемпотентности
POST /api/v1/orders
body: {"amount": 100}

# Клиент отправляет запрос
# Ответ теряется из-за сетевой ошибки
# Клиент повторяет запрос
# Результат: две транзакции вместо одной!

Решение 1: Idempotency Key (Ключ идемпотентности)

Это наиболее распространённый и рекомендуемый подход. Клиент отправляет уникальный ключ, сервер гарантирует, что запрос выполнится только один раз.

# Django/FastAPI пример
from uuid import uuid4
from django.db import models
from rest_framework.response import Response
from rest_framework.decorators import api_view

class IdempotencyKey(models.Model):
    """Хранит результаты идемпотентных операций"""
    key = models.CharField(max_length=255, unique=True, db_index=True)
    request_body = models.JSONField()
    response = models.JSONField()
    status_code = models.IntegerField()
    created_at = models.DateTimeField(auto_now_add=True)
    expires_at = models.DateTimeField()

class Order(models.Model):
    user = models.ForeignKey('User', on_delete=models.CASCADE)
    amount = models.DecimalField(max_digits=10, decimal_places=2)
    idempotency_key = models.CharField(max_length=255, unique=True)
    created_at = models.DateTimeField(auto_now_add=True)

@api_view(['POST'])
def create_order(request):
    """Идемпотентное создание заказа"""
    idempotency_key = request.headers.get('Idempotency-Key')
    
    if not idempotency_key:
        return Response(
            {'error': 'Idempotency-Key header required'},
            status=400
        )
    
    # Проверка: был ли уже такой запрос?
    try:
        cached = IdempotencyKey.objects.get(key=idempotency_key)
        # Запрос уже обработан, возвращаем закешированный ответ
        return Response(cached.response, status=cached.status_code)
    except IdempotencyKey.DoesNotExist:
        pass
    
    try:
        # Обработка запроса
        order = Order.objects.create(
            user=request.user,
            amount=request.data['amount'],
            idempotency_key=idempotency_key
        )
        
        response_data = {'id': order.id, 'amount': order.amount}
        response_status = 201
        
        # Сохраняем результат
        IdempotencyKey.objects.create(
            key=idempotency_key,
            request_body=request.data,
            response=response_data,
            status_code=response_status,
            expires_at=timezone.now() + timedelta(hours=24)
        )
        
        return Response(response_data, status=response_status)
    
    except Exception as e:
        # Сохраняем ошибку
        error_response = {'error': str(e)}
        response_status = 400
        
        IdempotencyKey.objects.create(
            key=idempotency_key,
            request_body=request.data,
            response=error_response,
            status_code=response_status,
            expires_at=timezone.now() + timedelta(hours=24)
        )
        
        return Response(error_response, status=response_status)

# Клиентская сторона
import requests
import uuid

def create_order(amount):
    idempotency_key = str(uuid.uuid4())
    
    response = requests.post(
        'https://api.example.com/api/v1/orders',
        json={'amount': amount},
        headers={'Idempotency-Key': idempotency_key}
    )
    
    # Даже если сетевая ошибка, мы сохраняем ключ
    # и можем повторить с тем же ключом
    return response.json()

Решение 2: Использование UNIQUE constraint

Добавить уникальное ограничение на бизнес-логику.

# Пример: платёжная система

class Payment(models.Model):
    order_id = models.IntegerField()
    transaction_id = models.CharField(max_length=255, unique=True)
    amount = models.DecimalField(max_digits=10, decimal_places=2)
    status = models.CharField(max_length=20)  # pending, success, failed
    created_at = models.DateTimeField(auto_now_add=True)
    
    class Meta:
        unique_together = ['order_id', 'transaction_id']

@api_view(['POST'])
def process_payment(request):
    try:
        payment = Payment.objects.create(
            order_id=request.data['order_id'],
            transaction_id=request.data['transaction_id'],
            amount=request.data['amount'],
            status='pending'
        )
        
        # Обработка платежа
        payment.status = 'success'
        payment.save()
        
        return Response({'status': 'success'})
    
    except IntegrityError:
        # Дублирующийся платёж — возвращаем существующий
        payment = Payment.objects.get(
            order_id=request.data['order_id'],
            transaction_id=request.data['transaction_id']
        )
        return Response({'status': payment.status})

Решение 3: Использование PUT вместо POST

Утверждение ресурса идемпотентно по определению.

# Вместо
POST /api/v1/users
body: {"id": 123, "name": "John"}

# Используй
PUT /api/v1/users/123
body: {"name": "John"}

# Третий запрос PUT не создаст дублирующийся ресурс
# Если ресурс уже существует, он обновится

@api_view(['PUT'])
def upsert_user(request, user_id):
    """Идемпотентное обновление или создание пользователя"""
    user, created = User.objects.update_or_create(
        id=user_id,
        defaults=request.data
    )
    
    status_code = 201 if created else 200
    return Response(UserSerializer(user).data, status=status_code)

Решение 4: Использование state machine (конечный автомат)

Разрешить переходы только в определённые состояния.

class Order(models.Model):
    STATUS_CHOICES = [
        ('pending', 'Pending'),
        ('confirmed', 'Confirmed'),
        ('shipped', 'Shipped'),
        ('delivered', 'Delivered'),
        ('cancelled', 'Cancelled')
    ]
    
    status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='pending')
    
    def confirm(self):
        if self.status != 'pending':
            raise ValueError(f"Cannot confirm order in {self.status} state")
        self.status = 'confirmed'
        self.save()

# Клиент повторяет запрос подтверждения
POST /api/v1/orders/123/confirm

# Первый запрос: order.status переходит pending → confirmed
# Второй запрос: order.status уже confirmed, операция отклонена
# (или операция игнорируется и возвращается 200 OK)

Решение 5: Версионирование ресурса

class Document(models.Model):
    content = models.TextField()
    version = models.IntegerField(default=1)
    updated_at = models.DateTimeField(auto_now=True)

@api_view(['POST'])
def update_document(request, doc_id):
    try:
        document = Document.objects.select_for_update().get(id=doc_id)
        
        # Проверка версии
        if document.version != request.data.get('version'):
            return Response(
                {'error': 'Version mismatch'},
                status=409  # Conflict
            )
        
        document.content = request.data['content']
        document.version += 1
        document.save()
        
        return Response({'version': document.version})
    
    except Document.DoesNotExist:
        return Response({'error': 'Document not found'}, status=404)

Лучшие практики

# 1. Используй UUID для идемпотентности ключей
idempotency_key = uuid.uuid4()

# 2. Убедись, что ключ уникален в системе
class IdempotencyKey(models.Model):
    key = models.CharField(max_length=255, unique=True, db_index=True)

# 3. Используй SELECT FOR UPDATE при обновлении
with transaction.atomic():
    obj = Model.objects.select_for_update().get(id=1)
    obj.field = new_value
    obj.save()

# 4. Кэшируй результаты на определённое время
IdempotencyKey.objects.create(
    key=idempotency_key,
    response=response_data,
    expires_at=timezone.now() + timedelta(hours=24)
)

# 5. Логируй все операции
import logging
logger = logging.getLogger(__name__)
logger.info(f"Processing idempotent request: {idempotency_key}")

Тестирование идемпотентности

import pytest
from django.test import Client

@pytest.mark.django_db
def test_idempotent_create_order():
    client = Client()
    idempotency_key = 'unique-key-123'
    
    # Первый запрос
    response1 = client.post(
        '/api/v1/orders',
        {'amount': 100},
        HTTP_IDEMPOTENCY_KEY=idempotency_key
    )
    assert response1.status_code == 201
    order_id_1 = response1.json()['id']
    
    # Второй запрос с тем же ключом
    response2 = client.post(
        '/api/v1/orders',
        {'amount': 100},
        HTTP_IDEMPOTENCY_KEY=idempotency_key
    )
    assert response2.status_code == 201
    order_id_2 = response2.json()['id']
    
    # Проверка: оба ответа идентичны
    assert order_id_1 == order_id_2
    assert Order.objects.count() == 1  # Только один заказ создан

Вывод: Идемпотентность в POST запросах достигается через:

  1. Idempotency Key в заголовках — самый надёжный метод
  2. Уникальные ограничения на уровне БД
  3. Использование PUT вместо POST
  4. Стейт-машины для контроля переходов
  5. Версионирование ресурсов

Выбор метода зависит от специфики приложения и требуемого уровня надёжности.