# Errors and retries

Interpret problem details and recover safely from validation, conflict, and rate-limit responses.

- Source URL: https://www.signcustomiser.com/help/api/guides/errors-and-retries/
- Markdown URL: https://www.signcustomiser.com/help/api/guides/errors-and-retries.md

## Guide

API v3 returns every non-2xx response as an RFC 9457 problem document with the media type `application/problem+json`. Branch on `status` and `code`, not on title or prose.

## Problem shape

```json
{
  "type": "https://www.signcustomiser.com/help/api/problems/invalid-pricing-model",
  "title": "Invalid pricing model",
  "status": 422,
  "code": "invalid_pricing_model",
  "detail": "frame_fit customisers cannot use letter price lists.",
  "errors": [
    {
      "pointer": "/pricing_model",
      "code": "unsupported_value",
      "allowed_values": ["simple_letter", "advanced_letter", "frame_fit"]
    }
  ],
  "request_id": "req_..."
}
```

Every problem has `type`, `title`, `status`, `code`, `detail`, and `request_id`.

- `type` links to documentation for the problem class.
- `code` is the stable machine identifier. Its meaning does not change within v3.
- `detail` describes this occurrence and can change. Do not parse it.
- `request_id` is safe to record and is the value to include when asking for support.
- `errors` appears when the server has field-level details. A body field uses a JSON Pointer such as `/pricing_model`; a query error may identify a `parameter` instead. Enum errors include `allowed_values`.

Do not assume `errors` is present. Authentication, authorisation, rate-limit, conflict, and server problems often have enough information at the top level.

## Decide by status and code

| Status | Meaning                                                   | Recovery                                                                                       |
| ------ | --------------------------------------------------------- | ---------------------------------------------------------------------------------------------- |
| `400`  | The request could not be parsed.                          | Fix its syntax or content type. Do not retry it unchanged.                                     |
| `401`  | The credential is missing, invalid, expired, or revoked.  | Replace the key or bearer header.                                                              |
| `403`  | The key is valid but lacks access.                        | For `insufficient_scope`, compare `required_scopes` with `granted_scopes`.                     |
| `404`  | The resource is unknown or does not belong to this Store. | Refresh the Store-scoped resource list; do not use the response to infer another Store's data. |
| `409`  | The request conflicts with current state.                 | Resolve the named conflict. Idempotency conflicts have the special rules below.                |
| `422`  | The request is valid JSON but violates the contract.      | Correct the fields named by `errors`; use `allowed_values` rather than guessing.               |
| `429`  | The key has reached a rate or workload limit.             | Wait for `Retry-After`, then retry as described below.                                         |
| `5xx`  | The server failed to complete the request.                | Retry with backoff. Preserve the same idempotency key and body for writes.                     |

## Retry reads

Retry a `GET` after a network failure, `429`, or `5xx`. Use capped exponential backoff with jitter. When a `429` includes a `Retry-After` header, wait at least that many seconds; the problem body also carries `retry_after` for clients that cannot inspect headers.

Do not automatically retry `400`, `401`, `403`, `404`, or `422` unchanged. Those responses need a credential, resource, or request correction first.

## Safely verify rate-limit recovery

Run this check only against a controlled non-production origin supplied for the exercise. Never run this against production or use a production default. Send only `GET /api/v3/stores/me` with a fresh disposable `sc_test_` key scoped to `store:read`. Make sure there is no other caller using the key.

Include `X-Sign-Customiser-Rate-Limit-Test: true` on every request in this task. For a test key, that header selects a separate diagnostic bucket with one accepted request in a five-second window. It does not consume the key's ordinary per-key quota; the ordinary per-IP backstop still applies. The diagnostic bucket is not enabled on production. Live keys stay on the ordinary quota, and requests without the header use the ordinary quota.

Use one long-lived serial client session for the whole task. Never start one curl process per request. Do not use `--parallel` or follow redirects. Do not use automatic retry, including `--retry`.

Set a hard cap of 3 requests, including exactly one post-wait retry. Start a monotonic 10-second burst deadline and a monotonic 20-second end-to-end deadline immediately before call 1. The burst deadline applies through the first `429`. Send requests serially and stop on the first `429`:

- Call 1 must return `2xx`.
- Call 2 must return `429 rate_limited`.
- Call 3 is the one post-wait retry and must return `2xx`.

For the `429`, read `Retry-After` from the header as an integer. Use the problem body's integer `retry_after` only as a Retry-After body fallback. Abort if the interval is missing, malformed, negative, or greater than 5 seconds. Otherwise, wait at least that many seconds and issue one identical GET before the end-to-end deadline. A failed recovery ends the check; make no further attempt.

Fail closed and abort before another request if the burst deadline expires before the first `429`, the end-to-end deadline expires before the recovery request, a transport fails, a status differs from the accounting above, or call 2 does not return `429`. The controller must also block any request beyond the hard cap. Revoke the disposable key afterwards.

## Retry writes with idempotency

Every v3 write with side effects accepts an `Idempotency-Key` header. Generate a unique value for one logical operation and keep it with that operation for at least 24 hours.

```http
Idempotency-Key: order-018f2f16-7d52-7a66-bf71-0b90b9d6fce1
```

If a write times out, loses its connection, returns `429`, or returns `5xx`, retry with the **same key and byte-equivalent body**. A completed operation replays its original response and includes `Idempotency-Replay: true`, so a transport failure does not create the resource twice.

Two conflict codes need different handling:

- `idempotency_key_in_flight`: the first request is still running. Wait for its `retry_after`, then send the same key and body again.
- `idempotency_key_conflict`: the key was already used with a different body. Do not overwrite its meaning. Correct the request, generate a new key, and submit it as a new logical operation.

A `422` validation response means the operation was not accepted. Correct the body and use a new idempotency key. The pricing validation endpoint is side-effect free and does not use idempotency.

## Logging without leaking data

Record the HTTP method, path template, status, stable `code`, `request_id`, attempt number, and idempotency key identifier. Do not log API keys, bearer headers, or full request bodies. Redact customer text and personal data before attaching a problem response to a ticket.
