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
Idempotency-Key headerGenerate 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.
- Create a booking
- Create a flight booking
- Create a stay booking
- Create a train booking
- Confirm a booking
- Cancel a booking
- Calculate booking costs
- Calculate flight booking costs
- Calculate cancellation costs
- Approve an approval request
- Reject an approval request
- Quote a train booking operation
- Execute a train booking operation
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.
- Re-quote a car fare
- Re-quote a flight fare
- Re-quote a stay fare
- Re-quote a train fare
- Replace a stay booking's rate
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 gets409instead 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 returns200with the current session as long as thenewTotalyou
send still matches the session's current pending total. If a later renewal
moved the total since your first attempt, the replay also returns409 PRICE_ACCEPTANCE_STALE— re-read the session and accept the new total.
Rate limiting and 429
429Every response — success or error — carries three rate-limit headers:
| Header | Meaning |
|---|---|
X-RateLimit-Limit | The size of your current window. |
X-RateLimit-Remaining | Requests left in the current window. |
X-RateLimit-Reset | Unix 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
| Situation | Safe to retry? |
|---|---|
429 RATE_LIMIT_EXCEEDED | Yes — wait for Retry-After, then retry with the same key. |
503 SERVICE_UNAVAILABLE, or a database/limiter outage | Yes — 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 above | Yes, 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 replay | Not guaranteed either way — see What to do when a call times out or fails. |
422 VALIDATION_ERROR or any other FIX_INPUT recovery action | No — fix the request first; retrying the same body repeats the same error. |
409 CONFLICT / BOOKING_CONFLICT / CANCELLATION_FEE_CHANGED / CHANGE_PRICE_CHANGED | No, not as-is — resolve the conflicting state (re-quote, re-fetch, or re-confirm) before trying again. |
410 OFFER_EXPIRED / QUOTE_EXPIRED | No — 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 a4xxvalidation 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
statusbefore 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 responseasync 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
- Errors — the full error
envelope andrecoveryActionvocabulary. - Bookings and trips — the booking lifecycle most
of these operations belong to. - Guest checkout — the checkout-session
renewal and price-acceptance calls. - Booking approval requests — the approve/reject
calls.
Updated 8 days ago