Errors, retries & idempotency¶
A production integration is defined by how it handles the unhappy path. This page covers the error envelope, which failures are safe to retry, and how to avoid duplicates.
The error envelope¶
Every error response — regardless of endpoint — has the same shape:
{
"error": {
"code": "not_found",
"message": "Content item not found.",
"request_id": "req_a1b2c3d4"
}
}
Validation errors add field-level detail:
{
"error": {
"code": "validation_error",
"message": "Validation failed.",
"request_id": "req_a1b2c3d4",
"field_errors": {
"email": ["A user with this email already exists."]
},
"non_field_errors": ["Cannot assign archived content."]
}
}
Branch on error.code, not on error.message — messages are for humans and may change. Always log request_id. It's the fastest path to a resolution when you contact support, and it's present on every response, success or failure.
Status codes¶
| HTTP | code |
Retry? | What it means |
|---|---|---|---|
| 400 | bad_request |
No | Malformed request. Fix the request. |
| 401 | unauthenticated / invalid_token |
No | Missing or invalid token. |
| 403 | forbidden |
No | Authenticated, but not permitted. |
| 404 | not_found |
No | Resource doesn't exist or isn't visible to your token. |
| 409 | conflict / duplicate |
No | Conflicts with current state (e.g. dropping a checked-in enrollment). |
| 410 | gone |
No | The resource existed but is permanently gone (e.g. an expired upload session). |
| 412 | precondition_failed |
After re-fetch | Your If-Match ETag is stale. See Concurrency. |
| 422 | validation_error |
No | The body failed validation. Inspect field_errors. |
| 429 | rate_limited |
Yes, after Retry-After |
Too many requests. |
| 500 | internal_error |
Yes, with backoff | Unexpected server error. Quote the request_id. |
| 503 | service_unavailable |
Yes, with backoff | A dependency (e.g. Mux, SCORM Cloud) is temporarily unavailable. |
The rule of thumb: 4xx is your fault, 5xx is ours. Don't retry a 4xx without changing the request — 429 and a re-fetched 412 are the only exceptions.
What's safe to retry¶
Retries are only safe for idempotent operations — those that produce the same result whether you call them once or five times.
| Operation | Idempotent? | Safe to blindly retry? |
|---|---|---|
GET (any read) |
Yes | Yes. |
PUT (declarative replace, e.g. track items/sections) |
Yes | Yes. |
DELETE |
Yes (a second delete returns 404) | Yes, tolerating 404. |
PATCH (update) |
Effectively yes | Yes, but prefer If-Match to avoid clobbering a concurrent change. |
Idempotent actions (checkin, bulk archive/unarchive) |
Yes, by design | Yes. |
POST (create) |
No | No — a naive retry can create a duplicate. See below. |
Several POST actions are deliberately idempotent and call it out in their description — for example, check-in returns 200 if the enrollment is already checked in, and bulk archive treats already-archived items as successes. When the reference says an action is idempotent, you can retry it freely.
Retrying creates without duplicating¶
Plain POST /…/ creates are not idempotent. To make creation safe:
- Create-or-get endpoints. Some creates already de-duplicate.
POST /assignments/returns201for a new assignment and200for an existing one (same user + content item);POST /enrollments/behaves the same way. For these, a retry is safe — you'll just get200the second time. - Check before you create. For endpoints without create-or-get semantics, query first (
GET /users/?email=…) and onlyPOSTif nothing matches. - Reconcile after a timeout. If a create times out with no response, don't immediately re-POST. Re-fetch by a natural key (email, name) to see whether the first attempt actually succeeded, then create only if it didn't.
Handling 429 (rate limits)¶
Every response carries rate-limit headers:
| Header | Meaning |
|---|---|
X-RateLimit-Limit |
Requests allowed per window. |
X-RateLimit-Remaining |
Requests left in the current window. |
X-RateLimit-Reset |
Seconds until the window resets. |
On a 429, the response includes a Retry-After header (seconds). Honor it — sleep for Retry-After and then retry. For everything else, watch X-RateLimit-Remaining and slow down before you hit zero rather than after.
Handling 5xx (backoff)¶
For 500 and 503, retry with exponential backoff and jitter: wait ~1s, then ~2s, ~4s, ~8s, with a small random offset so a fleet of clients doesn't retry in lockstep. Cap the total attempts (3–5 is typical) and surface the request_id if you give up.
import time, random
def with_retries(do_request, attempts=5):
for attempt in range(attempts):
resp = do_request()
if resp.status_code < 500 and resp.status_code != 429:
return resp
if resp.status_code == 429:
delay = int(resp.headers.get("Retry-After", 1))
else: # 5xx
delay = (2 ** attempt) + random.random()
time.sleep(delay)
return resp # exhausted; inspect resp.json()["error"]["request_id"]
Concurrency with ETags¶
Resources that support optimistic concurrency return an ETag header on GET. Send it back as If-Match on your PATCH:
GET /tracks/{public_id}/→ note theETagresponse header.PATCH /tracks/{public_id}/withIf-Match: "<etag>".- If someone else modified the resource in between, you get
412 precondition_failed. Re-fetch, re-apply your change, and retry. Never loop on a412without re-fetching — the ETag won't change on its own.
This is how you avoid the lost-update problem when two integrations (or an integration and a human in the UI) edit the same resource. See Conventions → ETags and concurrency.
Asynchronous failures¶
Some work happens after the API responds — bulk operations, uploads, recording transcoding. These don't fail in the original HTTP response; they fail in the status you poll for later (status: "failed" or "errored"). Always inspect the terminal status and any error_message / message field, not just the 202/201 that kicked the job off. See Bulk operations & async jobs.