Handle errors¶
Every error this API returns has the same body and a stable code. Branch on the
code. The HTTP status groups codes that need different handling: three codes
share 409, and four share 503. A handler written against the status alone
will retry what it should fix, and give up on what it should retry.
The one shape¶
{
"error": {
"code": "validation_failed",
"message": "/options: Unrecognized key: \"retain_hour\"",
"docs_url": "https://doc.cheap/docs/errors/validation_failed",
"request_id": "req_18542f0c-d7cc-400c-976e-6e3b29a07beb",
"event_id": null
}
}| Key | What to do with it |
|---|---|
code |
Branch on it. It is stable, and it is the only value here you should switch on |
message |
Log it, show it to a developer, and do not parse it |
docs_url |
The page for this code. Put it in your own log line and a support thread starts with the fix in it |
request_id |
Quote it in support. It identifies this one request in our logs |
event_id |
See below |
event_id is present on every error and non-null only when the failure was
unexpected and the service recorded it as something to look at. It is null
for every refusal you are meant to handle: a bad key, an empty balance, a rate
limit. Nothing is wrong on our side in those cases. When it is not null,
quoting it resolves to that one recorded failure.
Trigger one¶
This call sends a misspelled option. Strict validation rejects unknown keys rather than ignoring them, and the message names the path that failed.
curl
curl -X POST https://api.doc.cheap/v1/scans \
-H "Authorization: Bearer sk_sandbox_public" \
-H "Content-Type: application/json" \
-d '{"image": "aGk=", "options": {"retain_hour": 2}}'JavaScript
const response = await fetch("https://api.doc.cheap/v1/scans", {
method: "POST",
headers: {
Authorization: "Bearer sk_sandbox_public",
"Content-Type": "application/json",
},
body: JSON.stringify({ image: "aGk=", options: { retain_hour: 2 } }),
});
const body = await response.json();
if (!response.ok) {
console.error(response.status, body.error.code, body.error.docs_url);
}Python
import json
import urllib.error
import urllib.request
request = urllib.request.Request(
"https://api.doc.cheap/v1/scans",
data=json.dumps({"image": "aGk=", "options": {"retain_hour": 2}}).encode(),
headers={
"Authorization": "Bearer sk_sandbox_public",
"Content-Type": "application/json",
},
)
try:
with urllib.request.urlopen(request) as response:
status = response.status
body = json.load(response)
except urllib.error.HTTPError as failure:
status = failure.code
body = json.load(failure)
print(status, body["error"]["code"], body["error"]["docs_url"])Note that fetch does not raise on a 4xx, while urllib does. Read the body
in both branches: an error body is JSON and carries the code you need.
What to do with each code¶
The contract carries 21 codes, and every one of them has a page. Two are
unreachable with an API key, and are listed so that a docs_url never points
at nothing.
| Code | HTTP | Do this |
|---|---|---|
invalid_request |
400 | Fix the request. Retrying it unchanged fails again |
validation_failed |
422 | Fix the field the message names. Retrying it unchanged fails again |
unauthorized |
401 | Fix the key or the header. Stop until it is fixed |
registration_required |
403 | Register and use your own key. Waiting does not refill the allowance |
insufficient_credits |
402 | Top up, then retry. Nothing was charged and the engine never ran |
not_found |
404 | Stop. No such scan, or its retention window has passed |
idempotency_conflict |
409 | The key was used with a different body. Send this body under a new key |
idempotency_in_progress |
409 | Wait a moment, then retry the same request with the same key |
idempotency_replay_unavailable |
409 | The first answer is gone. Send the request again under a new key |
payload_too_large |
413 | Shrink the image. Retrying it unchanged fails again |
unsupported_media_type |
415 | Send Content-Type: application/json |
rate_limited |
429 | Wait the Retry-After seconds, then retry the same request |
document_repeated |
429 | Wait the Retry-After seconds, or register. The same image went up too often on the free sandbox |
internal_error |
500 | Retry once with backoff. Quote event_id if it keeps happening |
engine_unavailable |
503 | Retry with backoff. Nothing was charged |
service_unavailable |
503 | Retry after Retry-After. A store we depend on is away |
maintenance |
503 | Retry after Retry-After. Planned work, and nothing is charged |
rate_unavailable |
503 | Top-up path only. No price source agreed a rate; retry later |
topup_in_progress |
409 | Top-up path only. One is already settling for this account; wait |
impersonation_read_only |
403 | Not reachable with an API key |
payment_driver_unavailable |
501 | Not reachable with an API key |
The full table, with the cause behind each, is the error reference.
Retry the four 503s, and only those¶
Four codes share 503, and every one of them is worth retrying with backoff.
engine_unavailable, service_unavailable and maintenance cost nothing. A
credit is reserved before the engine is called, and released when it does not
answer. A failed scan leaves the balance where it was.
Honor Retry-After where the response carries it, rather than guessing an
interval. A retry that ignores it arrives inside the same closed window and
spends its attempt on a refusal.
Send an Idempotency-Key on the
original request. A retry after a timeout is the one case where you cannot
tell whether the first call landed. The key is what makes the answer safe.
The shape of a handler¶
Every code in the table falls into one of four buckets, and a handler needs one branch per bucket rather than twenty-one.
| Bucket | Codes | Branch |
|---|---|---|
| Fix the call | invalid_request, validation_failed, payload_too_large, unsupported_media_type |
Log the message, fail the operation, do not retry |
| Fix the account | unauthorized, registration_required, insufficient_credits |
Alert an operator; retry only after somebody acts |
| Wait and retry | rate_limited, document_repeated, the four 503s, internal_error |
Back off, honor Retry-After, cap the attempts |
| Arbitrate a replay | the three idempotency_ codes |
Keep the key, or take a new one, per the table above |
not_found is outside all four: it is an answer about a scan that is not
there, and the caller decides what that means.
Reading Retry-After¶
The header carries whole seconds. Parse it as an integer and clamp it to something your own system can wait for. Fall back to your own backoff when the header is absent.
curl
curl -sS -D - -o /dev/null -X POST https://api.doc.cheap/v1/scans \
-H "Authorization: Bearer sk_live_not_a_real_key" \
-H "Content-Type: application/json" \
-d '{"image": "aGk="}'JavaScript
const response = await fetch("https://api.doc.cheap/v1/scans", {
method: "POST",
headers: {
Authorization: "Bearer sk_live_not_a_real_key",
"Content-Type": "application/json",
},
body: JSON.stringify({ image: "aGk=" }),
});
const retryAfter = Number(response.headers.get("Retry-After") ?? 0);
const wait = Number.isFinite(retryAfter) && retryAfter > 0 ? retryAfter : 1;
console.log(response.status, wait);Python
import json
import urllib.error
import urllib.request
request = urllib.request.Request(
"https://api.doc.cheap/v1/scans",
data=json.dumps({"image": "aGk="}).encode(),
headers={
"Authorization": "Bearer sk_live_not_a_real_key",
"Content-Type": "application/json",
},
)
try:
with urllib.request.urlopen(request) as response:
status = response.status
headers = response.headers
except urllib.error.HTTPError as failure:
status = failure.code
headers = failure.headers
wait = int(headers.get("Retry-After") or 1)
print(status, wait)Those three send a key that does not exist, so they answer 401 and carry no
Retry-After. The fallback is what runs, which is the branch worth proving.
A 200 that recognized nothing is not an error¶
no_document_found, unreadable, unsupported_document and rejected come
back with HTTP 200 and a complete body. The engine ran; it found nothing it
could publish. A live key is not charged for any of them.
Branch on status for those, and on error.code for the table above. A
handler that treats no_document_found as a failure retries a photograph that
will never read. One that treats it as a success stores a document with no
fields in it.
What to log¶
One line per failure, carrying code, request_id, event_id and docs_url.
That is enough for someone else to open the right page and quote the right id.
Leave out the request body: it carries an identity document, which does not
belong in a log.
Next¶
- Retry safely with idempotency — the three conflict codes, and what each one arbitrates.
- The error reference — all 21 codes with their causes.
- HTTP status codes — which codes share a status, and why.