Idempotency and safe retries

Which calls accept an Idempotency-Key, what replaying one actually does, and how to tell whether a failed or rate-limited request is safe to retry.

What this covers

Which /v1 operations require an Idempotency-Key header, what replaying
that key does on each one, how the 429 rate-limit signals work, and how to
decide whether a failed or timed-out call is safe to retry.

How it works

The Idempotency-Key header

Generate a fresh key per logical operation — a UUID v4 is recommended, but
travel.tech accepts any non-empty string — and send it as:

Idempotency-Key: 018f2c3e-1234-7abc-8def-000000000001

Omitting it on an operation that requires it returns 422:

{
  "error": {
    "code": "VALIDATION_ERROR",
    "message": "IDEMPOTENCY_KEY_REQUIRED: The Idempotency-Key header is required for booking mutations.",
    "details": [],
    "recoveryAction": "FIX_INPUT",
    "retryAfterSeconds": null
  }
}

These operations currently require it, grouped below by what replaying the
key actually does.

Operations with no stored replay

For these, travel.tech keeps no cached response. Every call — first attempt
or retry, same key or not — runs again. The key is still required and is
forwarded on to the booking provider, but provider-side deduplication is not
guaranteed; see What to do when a call times out or
fails
below for how to decide
whether a retry is safe.

Operations with a 24-hour replay cache

These cache the first successful response under the pair of (the fare or
booking acted on, the key). Replaying the same key against the same resource
within 24 hours returns that stored response verbatim — even if the retried
request's body differs from the original one; the body is not re-checked on
a replay. A different key, or the same key against a different resource,
starts a brand-new attempt.

Checkout-session operations (single most recent key)

Two checkout-session operations remember only the most recently used key on
the session, not a full history of past keys:

  • Renew a selection hold replays the
    original renewal when you send the same key as your last call on that
    session. Sending a different key always starts a fresh re-lock, even
    while an earlier key's renewal is still in flight — a concurrent request
    carrying that earlier key gets 409 instead of running again. Keep the
    same key when retrying a failed renewal; rotating it starts a second
    re-lock attempt against the provider.
  • Accept a repriced total stores no
    response at all. It is idempotent by convergence instead: replaying any
    key returns 200 with the current session as long as the newTotal you
    send still matches the session's current pending total. If a later renewal
    moved the total since your first attempt, the replay also returns 409 PRICE_ACCEPTANCE_STALE — re-read the session and accept the new total.

Rate limiting and 429

Every response — success or error — carries three rate-limit headers:

HeaderMeaning
X-RateLimit-LimitThe size of your current window.
X-RateLimit-RemainingRequests left in the current window.
X-RateLimit-ResetUnix timestamp (seconds) when the window resets.

A 429 additionally carries a Retry-After header (seconds to wait) plus
the standard error envelope:

{
  "error": {
    "code": "RATE_LIMIT_EXCEEDED",
    "message": "Rate limit exceeded",
    "details": [],
    "recoveryAction": "RETRY_LATER",
    "retryAfterSeconds": null
  }
}

The envelope reserves a retryAfterSeconds field for this purpose, but on
RATE_LIMIT_EXCEEDED it is not populated — the Retry-After response header
is the only wait-time signal travel.tech sends on a 429. Read the wait time
from that header, not from the body.

What you need to handle

Retry-safety table

SituationSafe to retry?
429 RATE_LIMIT_EXCEEDEDYes — wait for Retry-After, then retry with the same key.
503 SERVICE_UNAVAILABLE, or a database/limiter outageYes — the request never reached the point of changing anything; back off and retry.
500 / 502 / 504 on one of the replay-cached or convergence-checked operations aboveYes, with the same key — the replay cache or convergence check returns whichever attempt actually went through.
500 / 502 / 504, or no response at all, on an operation with no stored replayNot guaranteed either way — see What to do when a call times out or fails.
422 VALIDATION_ERROR or any other FIX_INPUT recovery actionNo — fix the request first; retrying the same body repeats the same error.
409 CONFLICT / BOOKING_CONFLICT / CANCELLATION_FEE_CHANGED / CHANGE_PRICE_CHANGEDNo, not as-is — resolve the conflicting state (re-quote, re-fetch, or re-confirm) before trying again.
410 OFFER_EXPIRED / QUOTE_EXPIREDNo — the offer is gone; make a new selection.
403 FORBIDDEN (BLOCKED)Never — the caller is not permitted to perform the action.

See Errors for the full error
envelope shape and recoveryAction vocabulary this table draws from.

What to do when a call times out or fails

If a call from Operations with no stored
replay
drops the connection or times out
before you see a response, the Idempotency-Key alone cannot tell you
whether it took effect — travel.tech keeps nothing to check it against. What
to do next depends on what you can observe:

  • If the response you did receive carries a specific failure reason (a card
    decline, or a 4xx validation error), the attempt did not take effect —
    retrying is safe, with the same key or a new one.
  • If you received no response at all, or a 5xx/timeout with no specific
    failure reason, treat the outcome as unknown. Re-read the resource before
    retrying blind: GET /v1/{vertical}/bookings/{booking_id} tells you
    whether the booking exists before you retry a create, and tells you its
    status before you retry a confirm. Retrying a payment-carrying call
    without checking first risks a duplicate charge if the original attempt
    actually succeeded upstream.

Backoff

Back off on 429, 503, and connection failures. Honor Retry-After when
present; otherwise use exponential backoff with jitter, cap the delay, and
give up after a bounded number of attempts instead of retrying forever.

import random
import time

import httpx

MAX_ATTEMPTS = 5


def post_with_backoff(
    url: str, headers: dict, json_body: dict
) -> httpx.Response:
    for attempt in range(MAX_ATTEMPTS):
        response = httpx.post(url, headers=headers, json=json_body)
        if response.status_code != 429 and response.status_code < 500:
            return response

        retry_after = response.headers.get("Retry-After")
        delay = (
            float(retry_after)
            if retry_after is not None
            else min(2**attempt, 30) + random.uniform(0, 1)
        )
        time.sleep(delay)

    return response
async function postWithBackoff(
  url: string,
  headers: Record<string, string>,
  body: unknown,
  maxAttempts = 5,
): Promise<Response> {
  let response: Response;
  for (let attempt = 0; attempt < maxAttempts; attempt++) {
    response = await fetch(url, {
      method: "POST",
      headers,
      body: JSON.stringify(body),
    });
    if (response.status !== 429 && response.status < 500) {
      return response;
    }

    const retryAfter = response.headers.get("Retry-After");
    const delayMs = retryAfter
      ? Number(retryAfter) * 1000
      : Math.min(2 ** attempt, 30) * 1000 + Math.random() * 1000;
    await new Promise((resolve) => setTimeout(resolve, delayMs));
  }
  return response!;
}

Keep the same Idempotency-Key across every retry attempt of one logical
operation. Mint a new one only when you are intentionally starting a
different operation — rotating the key mid-retry defeats the protection the
header gives you on the operations that honor it.

Related guides


Did this page help you?