Привет, Хабр!

Представьте: клиент отправил важный POST‑запрос (например, создание заказа или списание денег), но из‑за сетевого сбоя не получил ответ. Не зная, что на сервере операция уже выполнилась, клиент пробует повторить запрос. Если бэкенд не подготовлен к таким дублям, итог может быть печальным: мы создадим две одинаковые записи вместо одной или, хуже того, спишем деньги с пользователя два раза. Как этого избежать? Правильный ответ — реализовать идемпотентность в API.

Идемпотентность запроса означает, что при многократном вызове одного и того же действия состояние системы изменится только один раз. Проще говоря, повторный идентичный запрос не должен ничего добавлять сверх того, что сделал первый. В ненадёжных сетевых условиях такой механизм незаменим для устойчивости системы. К слову, в HTTP некоторые методы по стандарту считаются идемпотентными: например, GET, PUT, DELETE. Их повторный вызов должен приводить к тому же эффекту, что и единичный, если сервер реализован корректно (не менять состояние более одного раза). А вот POST изначально неидемпотентен, два одинаковых POST‑запроса могут создать два ресурса. Однако мы можем сделать и POST идемпотентным, добавив специальный идентификатор для каждого запроса.

Ключ идемпотентности

Для обеспечения идемпотентности вводится понятие Idempotency Key, уникального ключа запроса. Это уникальное значение, которое генерируется на стороне клиента и отправляется на сервер вместе с запросом (обычно в HTTP‑заголовке Idempotency-Key). Рекомендуется использовать UUID v4 или другой случайный идентификатор с достаточной энтропией, чтобы практически исключить коллизии ключей. Клиент должен создавать новый ключ для каждой операции и повторно использовать тот же самый ключ при ретрае именно этой операции (например, если нужно повторно отправить тот же платеж).

Зачем это нужно? Сервер, получив запрос с таким ключом, сможет распознать повторные попытки и не выполнять операцию дважды. Алгоритм в общем виде простой: если запрос с данным ключом уже выполнялся раньше, сервер возвращает ранее сохранённый результат вместо повторного выполнения бизнес‑логики. Если же ключ виден впервые, то сервер выполняет запрос как обычно и сохраняет результат, привязав его к этому ключу на будущее.

Например, что может случится без идемпотентности: клиент дважды отправил запрос на списание 1000 ₽ по кредиту, и система выполнила операцию два раза — итоговый баланс уменьшился до 8000 ₽ (было два списания по 1000). Дубликат запроса сработал, потому что сервер не распознал, что это повтор. Если бы использовался ключ идемпотентности, этого бы не произошло — при втором запросе сервер понял бы, что такой ключ уже обработан, и вернул бы клиенту тот же ответ, не списывая деньги повторно (баланс остался бы 9000 ₽).

Теперь, когда идея ясна, возникает вопрос: как реализовать такой механизм?

Самый удобный путь — вынести логику идемпотентности в отдельный слой, например middleware. Т.е написать промежуточный обработчик, который будет перехватывать все входящие запросы до бизнес‑логики. В этом idempotency‑мидлваре мы и будем проверять/сохранять наш Idempotency Key. Такой подход позволяет отделить поддержку идемпотентности от основной логики приложения: контроллеры могут не знать про какие‑либо ключи, ими занимается инфраструктурный слой.

В итоге мы получаем единый стандарт обработки повторных запросов для всего API — и меньше шансов что‑то забыть где‑то включить.

Идемпотентность невозможна без хранения состояния на сервере. Нам нужно где‑то держать информацию о уже обработанных ключах и результатах запросов, чтобы иметь возможность вернуть ответ повторно. По сути, требуется некий кеш запросов: структура данных, где по ключу запроса можно узнать, выполнялся ли он и что вернул.

Простейший вариант — хранить такие данные прямо в памяти приложения (например, в глобальной карте). На первом этапе разработки это может сработать: ключи будут жить, пока работает процесс. Однако на практике нужно обычно несколько экземпляров сервиса (значит, нужна общая память между ними), да и перезапуск процесса очистит всю карту. Повторный запрос после перезапуска сервера уже не будет узнан. Поэтому от простого in‑memory решения быстро переходят к внешнему хранилищу. Рассмотрим два основных варианта: реляционную базу данных и Redis.

Реляционная база данных

Самый надёжный способ хранить данные об идемпотентных запросах — в базе данных. Можно завести специальную таблицу, где для каждого idempotency_key хранятся необходимые сведения: статус выполнения и результат. Например, схема может быть такой (псевдокод):

CREATE TABLE idempotency_keys (
    key UUID PRIMARY KEY,
    status VARCHAR(20) NOT NULL,    -- 'processing' или 'completed'
    response_code INT,
    response_body JSONB,
    payload_hash VARCHAR(64) NOT NULL,
    created_at TIMESTAMPTZ DEFAULT NOW(),
    ...
);

При поступлении нового запроса с ключом мы попытаемся вставить новую запись в эту таблицу. За счёт ограничения уникальности БД гарантирует, что в таблице не окажется две строки с одним ключом. Но просто INSERT недостаточно, нужно учесть гонки.

Что если два одинаковых запроса прилетят одновременно (например, на разные экземпляры сервера)? Оба почти одновременно сделают запрос к базе: увидят, что записи с таким ключом нет, и оба попытаются вставить новую строку. В итоге один из них может получить ошибку нарушения уникальности (Primary Key conflict), но бизнес‑логику к тому моменту, возможно, оба начнут выполнять! Мы получим двойную обработку, от которой пытались защититься.

Чтобы такого не произошло, операции проверки/вставки должны быть атомарными. Один из подходов заключается в том, чтобы использовать транзакцию с блокировкой ключа.

Например, в PostgreSQL можно в рамках транзакции выполнить SELECT ... FOR UPDATE по этому ключу. Если строки с таким значением ещё нет, СУБД поставит блокировку‑заглушку (gap lock) на потенциальное место вставки — и второй транзакции с тем же ключом придётся ждать. Затем первый запрос вставит строчку со статусом «processing» и зафиксирует транзакцию. Второй запрос, проснувшись, обнаружит, что запись уже появилась. Тут возможны варианты: можно сразу прочитать сохранённый результат и вернуть его; либо, если результат ещё не готов (мы вставили только статус, а сам запрос ещё в работе), вернуть ошибку‑конфликт или тоже подождать. Главное — обе параллельные попытки не выполняют бизнес‑логику.

В итоге только одна из них на самом деле создаст ресурс или выполнит действие, вторая же просто узнает результат первого. После того как первый запрос завершится, мы обновим запись в базе, проставив статус «completed» и сохранив результат (например, код 201 и тело ответа). Повторные запросы с тем же ключом смогут сразу получить эти сохранённые response_code и response_body из БД вместо повторного выполнения операции.

Итого по базе данных: она гарантирует уникальность ключей и через транзакции позволяет правильно упорядочить конкурентные запросы. Недостаток — накладные расходы: каждый идемпотентный запрос потребует хотя бы одного обращения к базе (а то и двух‑трёх, если делать SELECT + INSERT + UPDATE). Если нагрузка высокая, возможно, стоит рассмотреть более лёгкое хранилище.

Redis (или другой быстрый кэш)

Очень популярное решение использовать Redis для хранения состояния идемпотентности.

Мы можем хранить в нём пары ключ -> результат. К тому же, Redis доступен всем экземплярам нашего сервиса сразу, что важно для распределённого приложения. Минус в том, что данные в памяти менее устойчивы: при перезапуске Redis или сбросе кэша информация о обработанных запросах исчезнет. Однако для идемпотентности полная устойчивость не всегда и нужна, обычно достаточно хранить ключи в течение относительно короткого окна (скажем, 24 часа). Вероятность, что клиент случайно повторит тот же запрос спустя дни или недели, ничтожна. Поэтому многие выбирают Redis как компромисс: данные хранятся недолго, но доступ к ним очень быстрый.

Как избежать гонок в Redis? Не пытаться делать отдельно GET и затем SET, между ними может вклиниться другой запрос. Решение — использовать атомарные операции Redis. В нашем случае идеально подходит команда SET с опцией NX (Not eXists), которая производит запись только если ключ ещё не существовал, за одну операцию.

Мы можем выполнить, например:

SET idempotency:<ключ> "processing" EX 60 NX

Этот единственный запрос к Redis попытается установить значение "processing" для нового ключа с TTL (временем жизни) 60 секунд, только если такого ключа ещё нет. Благодаря флагу NX Redis гарантирует, что только один из конкурентных запросов сможет создать этот ключ. Первый запрос, поступивший в систему, успешно запишет свой ключ и как бы захватит_lock_ на время выполнения. Второй параллельный запрос с тем же ключом получит от Redis ответ, что ключ уже существует, и поймёт, что его опередили.

Далее сценарий похож на случай с базой, хотя реализуется чуть иначе. Первый запрос записал в Redis значение "processing" (обозначив, что работа началась) и пошёл выполнять бизнес‑логику. Второй запрос обнаружил, что ключ есть. Что ему делать? Он может попробовать подождать пару секунд и сделать GET этого ключа. Если значение по‑прежнему "processing", значит первый запрос ещё не закончил — можно вернуть клиенту ошибку 409 Conflict (мол, запрос уже в процессе).

Некоторые реализации сразу так и делают: дубликат запроса немедленно получает 409. Альтернативный подход: сервер сам некоторое время ждёт завершения первого запроса. Например, можно через несколько мгновений снова проверить Redis: если первый запрос уже успел записать окончательный результат (об этом чуть ниже) — сразу вернуть его второму клиенту. Если же по истечении таймаута результата всё нет, вернуть 409 Conflict или 429 Too Many Requests с заголовком Retry-After. Такой гибридный подход делает повторные запросы более «прозрачными» для клиента: нередко второй запрос просто подождёт лишние 100–200 мс и получит ответ, как будто выполнил операцию сам.

Мы немного забежали вперёд. Вернёмся к первому запросу: выполнив бизнес‑логику, он должен сохранить результат в хранилище. В случае Redis это означает записать в ключ не «processing», а, к примеру, сериализованный ответ (статус + тело). Можно либо обновить тот же ключ, либо установить новый ключ вида idempotency:<ключ>:result. Для простоты часто перезаписывают значение: было "processing", станет, например, "{\"status\":201,\"body\":\"...\"}" с более длинным TTL (например, 24 часа). Теперь Redis хранит итоговый ответ. Когда придёт повторный запрос с тем же ключом (даже через секунду, даже на другой сервер), мы сделаем GET и найдём готовый результат. Сервер вернёт этот кэшированный ответ вместо повторного выполнения бизнес‑логики. Таким образом достигается идемпотентность.

Мы установили TTL 60 секунд на метку «processing». Это своего рода защитный таймер: если первый запрос упал или завис, ключ сам удалится через минуту, и систему не заклинит навечно. Конечно, тут надо выбирать таймаут с запасом (время выполнения операции + немного сверху). А для финального результата имеет смысл ставить TTL подлиннее — часами или сутками. Обычно хватает 24 часов: ключи старше суток можно считать «протухшими» и удалять, чтобы не копились бесконечно (в Stripe именно так и делают: ключи хранятся минимум 24 часа, потом могут быть очищены).

Атомарность и защита от гонок

Как мы уже увидели, ключевой момент реализации — правильно обработать состояние гонки, когда два запроса с одним ключом поступают почти одновременно. Наивная реализация тут не сработает. Если проверки и обновления хранилища разделены, параллельные запросы легко обойдут нашу логику и оба решат, что они «первые». Решение — атомарные операции. В случае БД это транзакции/блокировки, в случае Redis — команды вроде SETNX или Lua‑скрипты, которые делают проверку и запись сразу. Всегда старайтесь использовать либо одну атомарную команду, либо блокировку на время проверки ключа — тогда только один поток «победит» и выполнит операциюzuplo.com. Иначе смысл идемпотентности теряется.

Справедливости ради, глобальная блокировка по ключу (например, мьютекс на уровне приложения) тоже решила бы проблему гонок, но она может стать узким местом. Лучше полагаться на атомарность самого хранилища, как описано выше, особенно если API масштабируется на несколько инстансов.

middleware для идемпотентности в Go

Напишем упрощённый вариант middleware на Go, реализующий описанные идеи. Используем стандартный net/http. Для простоты сделаем хранилище ключей в памяти (на одном сервере); при желании его легко заменить на реализацию с Redis или базой. Наш middleware будет проверять заголовок Idempotency-Key у каждого запроса на создание ресурса и обрабатывать дубликаты.

package main

import (
    "net/http"
    "net/http/httptest"
    "sync"
)

// Структура для хранения кэшированного ответа
type CachedResponse struct {
    StatusCode int
    Body       []byte
    Completed  bool
}

type MemoryStore struct {
    mu   sync.Mutex
    data map[string]*CachedResponse
}

func NewMemoryStore() *MemoryStore {
    return &MemoryStore{ data: make(map[string]*CachedResponse) }
}

// Получить сохранённый ответ по ключу (если есть)
func (m *MemoryStore) Get(key string) (*CachedResponse, bool) {
    m.mu.Lock()
    defer m.mu.Unlock()
    resp, exists := m.data[key]
    return resp, exists
}

// Попытаться зарезервировать ключ для нового запроса
func (m *MemoryStore) StartProcessing(key string) bool {
    m.mu.Lock()
    defer m.mu.Unlock()
    if _, exists := m.data[key]; exists {
        return false  // ключ уже есть
    }
    // Вставляем "пустую" запись, помечая, что запрос в работе
    m.data[key] = &CachedResponse{ Completed: false }
    return true
}

// Сохранить результат и отметить запрос завершённым
func (m *MemoryStore) Finish(key string, status int, body []byte) {
    m.mu.Lock()
    if resp, exists := m.data[key]; exists {
        resp.StatusCode = status
        resp.Body = body
        resp.Completed = true
    } else {
        // на всякий случай, если записи не было (не должно случиться)
        m.data[key] = &CachedResponse{ StatusCode: status, Body: body, Completed: true }
    }
    m.mu.Unlock()
}

// Middleware, реализующий идемпотентность
func IdempotencyMiddleware(store *MemoryStore, next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        key := r.Header.Get("Idempotency-Key")
        if key == "" {
            http.Error(w, "Idempotency-Key header required", http.StatusBadRequest)
            return
        }

        // Проверяем, не обработан ли уже такой ключ
        if cached, exists := store.Get(key); exists {
            if cached.Completed {
                // Отдаём сохранённый результат
                w.WriteHeader(cached.StatusCode)
                w.Write(cached.Body)
            } else {
                // Запрос с таким ключом ещё обрабатывается
                http.Error(w, "Duplicate request in progress", http.StatusConflict)
            }
            return
        }

        // Пытаемся зарезервировать ключ (пометить как "в работе")
        if !store.StartProcessing(key) {
            // Кто-то успел вставить ключ до нас
            if cached, exists := store.Get(key); exists && cached.Completed {
                // Если запрос как раз завершился, вернём результат
                w.WriteHeader(cached.StatusCode)
                w.Write(cached.Body)
            } else {
                // Иначе сообщим о конфликте (дубль в процессе)
                http.Error(w, "Duplicate request in progress", http.StatusConflict)
            }
            return
        }

        // Наш запрос первый – выполняем основную логику
        recorder := httptest.NewRecorder()
        next.ServeHTTP(recorder, r)

        // Сохраняем результат и отмечаем ключ завершённым
        store.Finish(key, recorder.Code, recorder.Body.Bytes())

        // Возвращаем клиенту ответ, полученный от основной логики
        for k, vals := range recorder.Header() {
            for _, v := range vals {
                w.Header().Add(k, v)
            }
        }
        w.WriteHeader(recorder.Code)
        w.Write(recorder.Body.Bytes())
    })
}

Cоздали простой MemoryStore с мьютексом, который хранит информацию о запросах. В реальности, как обсуждалось, вместо памяти лучше использовать внешнее хранилище, но интерфейс будет примерно такой же.

Что делает middleware:

  • Проверка заголовка. Если клиент не отправил Idempotency-Key, мы возвращаем ошибку 400. Это сделано намеренно: раз мы хотим гарантировать идемпотентность важных операций, клиент обязан генерировать ключ. Иначе мы не сможем отличить повторный запрос от нового. (В ваших API можно решить иначе: например, генерировать ключи на сервере, но обычно ответственность возлагают на потребителя.)

  • Поиск в кеше. Дальше мы проверяем в нашем хранилище, нет ли уже такого ключа. Если есть и запрос уже завершён (Completed == true), то у нас сохранён готовый ответ — его и отдаем клиенту. Клиент получит тот же статус и тело, что и при первом выполнении запроса. Бизнес‑логика (handler) в этом случае вообще не вызывается. Если же ключ есть, но помечен как незавершённый, значит, первый запрос ещё обрабатывается прямо сейчас. В нашем примере мы сразу отвечаем ошибкой 409 Conflict, давая понять: дубль запроса отклонён, оригинал ещё в работе. (Можно было, как говорилось, попытаться подождать — но для простоты логики мы этого не делаем.)

  • Резервирование ключа. Если ключ в хранилище не найден, значит запрос точно новый. Мы пытаемся зарезервировать его через store.StartProcessing(). Эта функция под мьютексом проверяет ещё раз (на случай гонки), что ключа нет, и вставляет пустой объект с Completed=false. С этого момента второй параллельный поток, даже если доберётся сюда, при вызове StartProcessing получит false (ключ уже существует). Таким образом, мы обеспечиваем атомарность: только один поток получит право выполнить бизнес‑логику.

  • Обработка запроса. Мы вызываем next.ServeHTTP(recorder, r), где next — это реальный обработчик (handler) нашего бизнеса. Чтобы перехватить его ответ, мы используем httptest.NewRecorder() — это вспомогательный буфер‑ResponseWriter. next пишет ответ в recorder (вместо сетевого соединения). После выполнения бизнес‑логики в recorder будут статус ответа, заголовки и тело.

  • Сохранение и возврат ответа. Вызываем store.Finish() — отмечаем, что запрос с данным ключом успешно выполнен, и сохраняем результат (код и тело). В реальной жизни здесь можно ещё сохранить заголовки, хотя в нашем MemoryStore мы этого не делаем (для простоты). Затем мы берём всё, что накопилось в recorder, и отправляем в оригинальный ResponseWriter w. То есть клиент получит ровно тот же ответ, что сгенерировала бизнес‑логика.

После этого запрос завершён. Если теперь придёт повтор с тем же ключом, он попадёт в ветку exists && Completed и сразу получит кэшированный результат.

Наш пример рассчитан на одно экземпляр приложения. В многосерверной среде MemoryStore нужно заменить на что‑то вроде RedisStore или DBStore, но принципы остаются те же. Например, можно реализовать StartProcessing через команду SETNX в Redis, как обсуждалось. Псевдокод такой:

res, err := redis.SetNX(ctx, key, "processing", 5*time.Minute).Result()
if err != nil { /* ошибка подключения к Redis */ }
if res { 
    // ключ успешно установился - мы первый запрос
} else {
    // ключ уже существует
    val, _ := redis.Get(ctx, key).Result()
    if val == "processing" {
        // другой запрос ещё выполняется -> 409 Conflict
    } else {
        // в val лежит готовый ответ -> возвращаем его
    }
}

Здесь мы ставим "processing" с TTL 5 минут, аналог нашего StartProcessing. Затем, если SetNX вернул false, читаем текущее значение. Если там уже финальный результат (например, JSON), сразу возвращаем его; если ещё «processing», отклоняем запрос. Первый запрос по завершении должен выполнить что‑то вроде:

redis.Set(ctx, key, <serialized_response>, 24*time.Hour)

чтобы сохранить итог. В случае с базой данных код будет выглядеть иначе (через INSERT … ON CONFLICT DO … или транзакцию), но идея та же: используем уникальность ключа и транзакционность.

Нюансы

При реализации идемпотентных API стоит учитывать ряд тонкостей:

  • Генерация ключей. Ключи должны быть достаточно случайными и непредсказуемыми. Нельзя использовать автоинкремент, последовательности вроде userID-operation-count и тому подобное — угадав такой ключ, злоумышленник мог бы получить чужой результат или повторно выполнить действие. Рекомендуются UUID v4 или другие криптостойкие случайные строки. Длина ключа обычно ограничена (например, Stripe принимает до 255 символов).

  • Срок жизни (TTL). Не храните ключи бесконечно, иначе база/кэш разрастутся до небес. Определите разумное время, в течение которого повторение запроса вероятно. Практика показывает, что 24 часа достаточно практически для любых ретраев пользователей. Можно выбирать и другие интервалы по бизнес‑требованиям. В памяти или Redis можно сразу ставить TTL для ключа, в БД придётся периодически чистить старые записи кроном или отдельной горутиной.

  • Сопоставление данных запроса. Если клиент по ошибке использует один и тот же Idempotency‑Key для разных операций, нельзя позволить ему получить некорректный ответ. Сервер должен проверять, что повторный запрос полностью совпадает с оригинальным (тип и параметры). Например, если первый запрос с ключом X создавал платеж на 1000 ₽, а второй запрос с тем же X вдруг пытается создать другого пользователя — это явная ошибка клиента. Такой запрос надо отвергнуть (например, ответом 422 Unprocessable Entity), а не притворяться, будто он успешно выполнился. Для этого обычно при первом запросе вместе с результатом сохраняют отпечатку (hash) запроса, например, хэш тела JSON. При повторном запросе сравнивают хэши: если отличаются, возвращают ошибку (никаких данных из первого запроса, конечно, не выдавая). В нашей простой реализации мы этого не делали, но в продакшене стоит добавить.

  • Scope (область) ключей. Продумайте область уникальности ключей. В большинстве случаев достаточно глобального пространства (UUID и так глобально уникален). Но иногда, например, можно включить в ключ идентификатор пользователя или название метода, чтобы избегать коллизий между разными типами операций. Главное никогда не возвращать данные одного пользователя другому. Если вдруг случится пересечение ключей (теоретически возможно при очень большой нагрузке и плохом генераторе), убедитесь, что пользователь A не увидит результат запроса пользователя B. В Stripe, например, идемпотентные ключи привязаны к конкретному API ключу/аккаунту, а при совпадении разных параметров запрос отвергается.

  • Кэширование ошибок. Должны ли мы сохранять и возвращать ошибки так же, как успешные ответы? Тут есть нюанс. Если ошибка однозначно постоянная (например, 400 — неверный запрос, или 404 — несуществующий ресурс), то закэшировать её нормально: повторный такой же некорректный запрос всё равно не пройдет. А вот если ошибка временная (скажем, 500 Internal Server Error из‑за временной проблемы) — что делать? Stripe, как было упомянуто, сохраняет любой результат, даже 500, и повторный запрос вернёт ту же 500. Неважно, что там пошло не так, повтор не вызовет сторонних эффектов. Но некоторые системы предпочитают не кешировать ответы, сигнализирующие о временных проблемах (например, таймаут внешнего сервиса). Тогда повторный запрос сможет выполниться заново и, возможно, успешно. Решение зависит от характера вашего приложения. Главное документировать это для пользователей API. Часто делают так: кешируются результаты 2xx и бизнес‑ошибки (например, 4xx, если создание сущности не прошло валидацию, то смысл повторять?), а вот серьезные сбои (502 Bad Gateway, 503 Service Unavailable) можно не сохранять, позволяя повтору попытаться еще раз.

  • Возврат Retry‑After. Если вы реализуете неблокирующий подход (то есть при параллельных дубликатах сразу даёте 409 Conflict), стоит добавить заголовок Retry-After в ответ. Укажите время (в секундах или HTTP‑дате), через которое имеет смысл попробовать снова. Например, если вы храните ключ 24 часа, а конфликты возможны только при одновременных запросах, можно ставить Retry-After: 5, мол, повтори через 5 секунд, первый поток, вероятно, уже закончит. Это улучшит опыт интеграции: клиент сразу поймёт, через сколько времени нужно ретраить.

Заключение

Все эти принципы помогут вам построить действительно устойчивые к сбоям API, где каждый запрос выполняется ровно один раз, сколько бы раз его ни отправили. Пусть ни один дубль не проскочит.


Для разработчиков, углубленно работающих с Go и сталкивающихся с задачами построения отказоустойчивых и масштабируемых систем, открыт набор на курс «Микросервисы на Go». Для всех, кто хочет детальнее ознакомиться с программой, преподаватели курса бесплатно проведут демо-уроки:

  • Применение распределённых конфигураций для управления микросервисами на Go (5 ноября в 20:00). Записаться

  • Генерация gRPC, API‑Gateway и Swagger на основе единой схемы proto3 (12 ноября в 20:00). Записаться

Рост в IT быстрее с Подпиской — дает доступ к 3-м курсам в месяц по цене одного. Подробнее

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


  1. chechyotka
    29.10.2025 10:55

    Такой подход позволяет отделить поддержку идемпотентности от основной логики приложения: контроллеры могут не знать про какие‑либо ключи, ими занимается инфраструктурный слой.

    А как тогда сервис понимает, что ему надо просто вернуть готовый ответ, а не повторить процессинг во второй раз?


    1. chechyotka
      29.10.2025 10:55

      Извиняюсь, написал вопрос раньше времени, увидел про кеш с ответами

      // Структура для хранения кэшированного ответа
      type CachedResponse struct {
          StatusCode int
          Body       []byte
          Completed  bool
      }