Skip to content

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/ returns 201 for a new assignment and 200 for an existing one (same user + content item); POST /enrollments/ behaves the same way. For these, a retry is safe — you'll just get 200 the second time.
  • Check before you create. For endpoints without create-or-get semantics, query first (GET /users/?email=…) and only POST if 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:

  1. GET /tracks/{public_id}/ → note the ETag response header.
  2. PATCH /tracks/{public_id}/ with If-Match: "<etag>".
  3. If someone else modified the resource in between, you get 412 precondition_failed. Re-fetch, re-apply your change, and retry. Never loop on a 412 without 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.