Как обработать ситуацию, когда запрос успешно дошёл до сервера, но ответ не был отправлен пользователю?

«Как обработать ситуацию, когда запрос успешно дошёл до сервера, но ответ не был отправлен пользователю?» — вопрос из категории Веб-серверы и балансировка, который задают на 23% собеседований Devops Инженер. Ниже — развёрнутый ответ с разбором ключевых моментов.

Ответ

В распределённых системах, которые я проектировал, такая ситуация — классический сценарий частичного отказа. Клиент получает таймаут или разрыв соединения, в то время как сервер уже обработал запрос (например, списал деньги, создал заказ). Решение строится на двух принципах: идемпотентность операций и механизм повторов (retry) на стороне клиента.

1. Проектирование идемпотентного API: Все небезопасные операции (POST, PUT, DELETE) должны быть идемпотентными. Я добиваюсь этого, требуя от клиента отправлять уникальный идемпотентный ключ (Idempotency-Key) в заголовке запроса (например, X-Idempotency-Key: <uuid>). Сервер обязан проверять этот ключ.

2. Реализация на стороне сервера (бэкенд + кэш): При получении запроса с таким ключом:

  • Сервер проверяет, не обработан ли уже запрос с этим ключом, выполняя GET в быстром хранилище (например, Redis).
  • Если результат есть — он немедленно возвращается.
  • Если нет — запрос обрабатывается, а его результат сохраняется в Redis с TTL (например, 24 часа) на случай повторной отправки.

Пример middleware на Go для Gin framework:

package middleware

import (
    "github.com/gin-gonic/gin"
    "github.com/go-redis/redis/v8"
    "net/http"
)

func Idempotency(redisClient *redis.Client) gin.HandlerFunc {
    return func(c *gin.Context) {
        // Проверяем только для небезопасных методов
        if c.Request.Method == http.MethodPost || c.Request.Method == http.MethodPut || c.Request.Method == http.MethodPatch {
            idempotencyKey := c.GetHeader("X-Idempotency-Key")
            if idempotencyKey == "" {
                c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Idempotency-Key header required"})
                return
            }

            // Пробуем получить сохранённый ответ
            cachedResponse, err := redisClient.Get(c, "idempotency:"+idempotencyKey).Result()
            if err == nil {
                // Если нашли, отдаём сохранённый ответ и завершаем обработку
                c.Data(http.StatusOK, "application/json", []byte(cachedResponse))
                c.Abort()
                return
            }

            // Если ключа нет, обрабатываем запрос и перехватываем ответ
            c.Next()

            // После обработки сохраняем успешный ответ (статус 2xx) в Redis
            status := c.Writer.Status()
            if status >= 200 && status < 300 {
                // Здесь нужно захватить тело ответа (требует дополнительного буферизации writer)
                // Для примера опустим эту логику
                redisClient.Set(c, "idempotency:"+idempotencyKey, "serialized_response", 24*time.Hour)
            }
        } else {
            c.Next()
        }
    }
}

3. Стратегия повторов на стороне клиента: Клиентское приложение (или API-гейтвей) должно использовать экспоненциальную задержку (exponential backoff) при повторах и отправлять тот же идемпотентный ключ. Например, использовать библиотеку retry в сочетании с axios в Node.js.

4. Инфраструктурные гарантии:

  • Балансировщики нагрузки (Nginx/HAProxy): Настраиваю правильные таймауты (proxy_read_timeout, proxy_connect_timeout), чтобы они превышали время обработки на бэкенде.
  • Мониторинг: Настраиваю алерты на высокий уровень 5xx ошибок и таймаутов, которые могут указывать на проблему. Такой подход гарантирует, что даже в условиях нестабильной сети или временной недоступности сервиса не произойдёт дублирования критических операций.