problem+json, idempotency keys, and backing off
Errors and retries
Every error is RFC 9457 problem+json.
{
"type": "https://devs.inittasks.com/errors/validation_failed",
"title": "Validation failed",
"status": 422,
"detail": "a sentence for a developer; never branch on it",
"instance": "/todos/8E4F2A1B",
"request_id": "3f9a1c7e-2b44-4d1e-9c30-5a7e8b2d1f60",
"errors": [{ "pointer": "/title", "message": "must be at most 512 characters" }]
}Branch on type. Never branch on detail, and never on the status alone, because several codes share a status. Every type is a live page in the error catalogue.
Keep request_id if you report a problem. It is the only handle that ties your call to the server log, and the log holds no request bodies.
Which errors are worth retrying
| you get | retry | why |
|---|---|---|
| rate_limited (429) | yes, after Retry-After |
a bucket refills |
| internal (500) | yes, with backoff | the server failed, your request did not |
| validation_failed (422) | no | it will fail identically forever |
| unauthorized (401) | no | refresh the token first, then send it again |
| grant_revoked (401) | no | the grant is gone. Send the user through approval |
| conflict (409) | no | a reused key with a different body is a bug, not a blip |
Idempotency keys
A write that times out may still have succeeded. Retrying it without a key creates a second row.
Send Idempotency-Key on every write. The same key with the same body replays the original response for 24 hours. The same key with a different body is a conflict, because it means two different operations were given the same name.
Generate the key once per logical operation, outside your retry loop.
#!/usr/bin/env bash
# One key per logical operation, generated ONCE and reused by every retry.
KEY=$(uuidgen)
for attempt in 1 2 3 4 5; do
response=$(curl -s -w '\n%{http_code}' https://api.inittasks.com/v1/todos \
-X POST \
-H "Authorization: Bearer $INITTASKS_TOKEN" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $KEY" \
-d '{"title":"buy milk"}')
status=$(printf '%s' "$response" | tail -n1)
body=$(printf '%s' "$response" | sed '$d')
case "$status" in
2*) printf '%s\n' "$body"; exit 0 ;;
429|5*)
# Back off. Retry-After is on a 429; this doubles from one second.
sleep $(( 2 ** (attempt - 1) ))
;;
*) printf 'giving up: %s\n' "$body" >&2; exit 1 ;;
esac
doneA key generated inside the loop defeats the whole mechanism, which is the bug this example exists to prevent.
Backing off
On a 429, sleep for Retry-After seconds. It is a number the server gives you, not a guess.
On a 5xx, double the wait each attempt and add jitter. Without jitter, a server restart gathers every client into one retry at the same instant.
Give up after five attempts and surface the failure. A write that has failed five times is not going to succeed on the sixth, and a queue that never drains is harder to debug than an error.
Creating with your own id
You can supply id on a create. A second create with the same id returns duplicate_id, which usually means your first write landed. Fetch the row and carry on.
This is a second way to make a create safe to repeat, and it survives longer than the 24-hour idempotency window.
Next
- Rate limits for the buckets and the headers.
- The error catalogue for one page per code.