Errors, Retries, and Jobs
The API reference documents each endpoint's shape; this page documents the CONTRACT that spans all of them: how the server fails, what each failure commits to, which failures deserve a retry, and how work that outgrows a request becomes a background job. Two principles govern everything here. First, a deterministic client fault is never a 500: if resending the same bytes must fail again, the status says so. Second, the same cause answers the same way everywhere: an oversized input reports identically from extraction, summarization, or splitting, synchronous or async.
1The job contract, precisely#
Every task endpoint (documents, PDFs, extraction, analysis, embeddings, transcription, search operations) runs under one execution contract with three modes. Chat is the exception: it streams instead, and never becomes a job.
| Mode | When | Behavior |
|---|---|---|
| Synchronous | Sync Timeout is 0, the default |
The request blocks until the work finishes and returns the result inline, however long it takes. |
| Synchronous with fallback | Sync Timeout is positive (Admin Console, General) |
The work races the timeout. Finished in time: the result returns inline, and no job ever existed. Not finished: the work CONTINUES in the background and the caller gets 202 Accepted with a job_id. |
| Forced async | The request carries the header Prefer: respond-async |
202 Accepted immediately, before any work runs. Per call, honored on every task endpoint, and independent of the server's timeout setting, including a server configured fully synchronous. |
The header is the mechanism to design around: a client that WANTS the job workflow (bulk
processing, queue workers, anything that must not hold connections open) sends
Prefer: respond-async on the first call and never depends on server configuration or on
how long the work happens to take. A client that wants simplicity sends nothing and, on a
default server, gets plain request-response.
The 202 body is minimal on purpose:
{ "job_id": "9f2c...", "status": "processing", "message": "Structured extraction in progress. Poll GET /lmkit/v1/jobs/{job_id} for status." }
2Polling: one endpoint, four terminal truths#
GET /lmkit/v1/jobs/{job_id} returns the job's full state:
| Field | Meaning |
|---|---|
status |
processing, completed, failed, or cancelled. The first is the only non-terminal state. |
result |
The ORIGINAL endpoint response, exactly as the synchronous call would have returned it. Populated only when completed. |
error, error_code, error_reason |
On failure: the human message, the HTTP-equivalent status (413, 415, ...), and the machine reason (section 4). |
progress_current, progress_total |
Live progress where the work tracks it (pages processed, for instance); null where it does not. Drive progress bars from these, never from elapsed time. |
created_at, completed_at |
UTC timestamps; completed_at is null while processing. |
DELETE /lmkit/v1/jobs/{job_id} cancels a job still processing; the job reports
cancelled and, on the synchronous path, the caller receives status 499.
Three properties complete the contract:
- Jobs are private to their key. Polling and cancellation match the creating API key's owner: one key can neither observe nor cancel another's jobs. The operator sees and can cancel everything from the Admin Console's active-jobs view.
- Results are held for one hour, in memory. A completed job's result stays fetchable for an hour after completion, then is discarded; a server restart discards pending and completed jobs alike. Poll promptly, fetch the result once, and store it on your side. Treat a 404 from the poll as "unknown or expired", not as failure. On a load-balanced fleet a job id polls on the node that minted it, so route async bulk callers with affinity rather than round-robin per request (Scaling Out).
- Only real background work is a job. A request answered inline never registers as a job, so the admin's active-jobs count means detached work, not traffic.
A robust polling loop is short: poll on a modest interval (a few seconds, or scaled by
progress_total when present), stop on any status other than processing, branch on
error_reason when failed, and give up only on 404.
3Admission: the in-flight ceiling#
Before any work starts, synchronous or not, the server bounds how many task operations one
API key may have in flight at once (MaxJobsPerOwner, default 100). At the ceiling the
request is rejected immediately with 429, reason too_many_inflight_jobs, and
Retry-After: 5; nothing was executed, so the retry is always safe. The slot frees when
work COMPLETES, not when a 202 returns, so a bulk pipeline should submit, poll, and
drain rather than fire thousands of accepts and hope.
4Failures: same cause, same answer, both channels#
Job failures and synchronous failures are classified by the same code, so a poller and a blocking caller learn identical truths. The reasons are stable, machine-readable snake_case; the important ones:
error_code |
error_reason |
It means | Retry? |
|---|---|---|---|
| 413 | input_too_large |
The content exceeds what one inference pass can hold: a property of the REQUEST. | Only after shrinking the input. |
| 415 | unsupported_file_type |
The bytes are a format no document pipeline ingests. Deterministic for these bytes. | Never. |
| 4xx | password_required and the other PDF reasons |
The document cannot be processed as sent (encrypted, corrupt); the reason names the exact cause. | Only after fixing the input. |
| 503 | service_unavailable |
The device was momentarily out of memory, or capacity was shed by policy. Transient by construction. | Yes, honoring Retry-After. |
On the synchronous channel the failed body carries the same pair as { "error": "<reason>", "message": "<human text>" }. Branch your handling on the reason, never by
parsing prose.
5The status map, and what each commits to#
- 400: the request itself is malformed or fails validation; field-level details are in
the body. Includes unknown names that must refuse loudly (an unknown
agent, for instance). Fix the request; never retry as-is. - 401: no valid API key where one is required.
- 404: the addressed thing does not exist for you: an unknown or expired
file_idorjob_id. Remember jobs expire (section 2) and uploaded files expire with the configured retention. - 413: two flavors, both actionable. An oversized REQUEST BODY is refused at the door,
and the message names the remedy: upload the file first and pass its id instead of
embedding base64. An oversized INPUT for inference is
input_too_largeabove. - 415 / PDF 4xx family: deterministic content refusals, reasons attached.
- 429: the in-flight ceiling (section 3).
Retry-Afteris set. - 499: the CALLER closed the connection or cancelled; recorded, never an error.
- 500: an unhandled server fault. The body is a sanitized generic message by design; the full cause is in the server logs, and the request record carries the exception summary for the operator.
- 503 +
Retry-After: load shedding as policy, three known sources: every inference slot busy under the Reject saturation policy (retry after ~1s), transient device memory pressure (~5s), and a momentarily unavailable search database. All are "try again", never "your request is broken".
The envelope: errors arrive as the OpenAI-style object { "error": { "message", "type", ... } } on the OpenAI-compatible surface, and as the flat { "error": "..." }
shape on Ollama-dialect routes, so existing clients of either dialect parse failures
without adaptation. Task endpoints use the { error, message } pair shown above.
Streaming is its own channel: a streamed chat that fails after tokens began arrives as
an error EVENT on the stream, because the HTTP status was already sent. A streaming
client must handle both the status code (failures before start) and the error event
(failures after).
6Retry discipline, stated once#
- Retry 503 (after
Retry-After), 429 (after draining), and transport-level failures (connection reset, timeout on YOUR side), with bounded backoff. - Never retry 4xx unchanged: they are deterministic verdicts on the request.
- Task submissions are not deduplicated: a retried submission runs the work again. Task
endpoints do not mutate your inputs (an uploaded file is read, never changed), so the
cost of a duplicate is compute, not corruption; still, on ambiguous transport failures
after a
202, poll before resubmitting. - Long work should not be retried into a synchronous window: force async with
Prefer: respond-asyncand make the poll loop the single place that handles slowness.
7Stated plainly#
- Three execution modes, one header:
Prefer: respond-asyncgives any client the job workflow on the first call, whatever the server's timeout setting. - A job's
resultis byte-for-byte the synchronous response; failures carry an HTTP-equivalent code plus a stable machine reason on both channels. - Results live one hour in memory and jobs are key-private: poll promptly, store the result yourself, and branch on reasons, not prose.
- 5xx means retry, deterministic 4xx means fix, and
Retry-Afteris always honored by the well-behaved client you are writing.