init.Tasks openapi.json inittasks.com

problem+json, idempotency keys, and backing off

Errors and retries

Every error is RFC 9457 problem+json.

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.

bash
#!/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
done

A 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