Идемпотентность HTTP и Idempotency-Key

О лекции

По семантике HTTP методы GET, PUT и DELETE идемпотентны, POST — нет. Повтор POST после таймаута без ключа легко создаёт вторую операцию: запись могла пройти, а ответ потеряться.

API и RPC http собеседование

Идемпотентный метод: повтор запроса с тем же смыслом не меняет состояние сверх первого успеха. Безопасный метод (GET, HEAD) ещё и без побочных эффектов. Поэтому GET можно кэшировать.

Это контракт протокола, а не «мой хендлер случайно так написан». Если POST создаёт заказ, протокол не обещает, что второй такой же POST безвреден.

Сеть рвётся в худший момент. Сервер записал и упал до ответа. Прокси вернул 502. Клиентский таймаут сработал, пока handler ещё коммитил. Клиент не знает, прошла операция или нет. Слепой ретрай POST — два списания. Таймаут ≠ «сервер точно ничего не записал».

Методы

  • GET, HEAD — безопасные. Повтор не создаёт заказ. Кэш и прокси на это опираются.
  • PUT — «сделай ресурс таким»: повтор с тем же телом даёт тот же результат.
  • DELETE — повтор после успеха часто 404. Состояние «ресурса нет» то же.
  • POST — «создай / запусти действие». Без ключа повтор опасен.
  • PATCH не обязан быть идемпотентным. Если делаете его таковым — опишите в контракте.

Idempotency-Key

Idempotency-Key простыми словами: клиент даёт уникальный номер операции. Сервер запоминает этот номер и результат. Повтор с тем же номером — тот же ответ, вторая проводка не создаётся.

Пример. Оплата: клиент шлёт POST /charge с ключом abc-123. Сервер списал деньги и упал до ответа. Клиент не знает, прошло или нет. Шлёт тот же POST с тем же ключом. Сервер видит abc-123 в таблице — возвращает старый успех, второй раз не списывает.

func charge(w http.ResponseWriter, r *http.Request) {
	key := r.Header.Get("Idempotency-Key")
	if key == "" {
		http.Error(w, "Idempotency-Key required", http.StatusBadRequest)
		return
	}
	if resp, ok := store.Get(r.Context(), userID(r), key); ok {
		writeJSON(w, resp.Status, resp.Body) // тот же ответ
		return
	}
	result, err := billing.Charge(r.Context(), parse(r.Body))
	store.Put(userID(r), key, result) // TTL сутки, скоуп пользователь+операция
	writeJSON(w, http.StatusCreated, result)
}

Ключ на POST: повтор после таймаута возвращает сохранённый результат, а не вторую проводку.

Ключ обычно UUID. Сервер хранит ключ → результат. Или «in-progress», чтобы параллельный повтор не начал вторую работу. Ключи живут ограниченно. Скоуп — пользователь плюс операция. Иначе один UUID на весь сервис столкнёт чужие запросы.

Ретраи безопасны, если метод идемпотентен или есть ключ. Или натуральный UNIQUE в БД: order_id. PUT по каноническому URL часто обходится без заголовка.

Не путайте идемпотентность с «безопасностью GET». PUT меняет состояние, но повтор того же PUT не должен удваивать эффект. Очереди at-least-once дают ту же задачу: сообщение могут доставить дважды. Идемпотентный consumer и Idempotency-Key — один навык, разные входы.

Типичная ошибка

Ретраить POST после таймаута «потому что ошибки не было в логах клиента». Запись могла пройти. Вторая ошибка — считать любой JSON-метод идемпотентным или путать идемпотентность с безопасностью GET.

На собеседовании

PUT по протоколу идемпотентен, POST — нет без ключа. Idempotency-Key лечит повтор после таймаута. Свяжите с at-least-once очередью: без идемпотентного консьюмера будет то же удвоение.