LibrariEval System
LeaderboardDocsPricingSign UpLogin
Librari Evals — Docs

Extract Large Documents with Async Jobs

Submit long-running extractions to /api/extract-async, poll a job id, and collect results that would time out a synchronous call.

A synchronous POST /api/extract call has a hard ceiling: the platform's ingress cuts any HTTP request at ~240 seconds, no matter what the server is doing. Most extractions finish in seconds and never notice. But a large document on a vision-path schema — think a 100+ page PDF where the model reads every page image — can legitimately need more model time than a single HTTP request is allowed to live. Retrying doesn't help; the same wall is waiting every time.

POST /api/extract-async is the same extraction without the ceiling. You submit the same fields, get back a job id immediately, and poll until the job lands on a result. The extraction runs server-side on a queue with its own retry handling, so a connection reset halfway through a five-minute model call becomes the server's problem, not yours.

Sync or async?

/api/extract (sync)/api/extract-async
Best forDocuments that extract in seconds to ~3 minutes — the vast majority.Large documents, vision-heavy schemas, batch pipelines that don't need an answer in-request.
LatencyOne round trip.Submit + poll — a few seconds of overhead on top of the extraction itself.
Time budgetHard ~240s ceiling (gateway).Up to 15 minutes of extraction time per attempt, retried on transient provider failures.
Transient provider errorsSurfaced to you as retryable errors — you resubmit.Retried server-side (up to 3 attempts with backoff) before the job is marked failed.

A reasonable integration strategy: call the sync endpoint by default, and route documents to async when they're big (page count is a good heuristic), or after a sync attempt dies with a gateway 504.

What you'll need

Exactly what the sync endpoint needs — same bearer token, same Request Schema Version UUID, same Model Deployment UUID with a tenant LLM API key assigned. If you already call /api/extract, you have everything.

Submit: POST /api/extract-async

MethodPOST
URLhttps://evals.thelibrari.com/api/extract-async (or your own host)
AuthAuthorization: Bearer <extraction-api-key>
Bodymultipart/form-data — same fields as /api/extract: file, requestSchemaVersionId, modelDeploymentId, and the optional enumOverrides. Overrides are validated at submit time (a bad one is a synchronous 400, no job is created) and stored with the job, so the worker applies exactly what you sent.

Every deterministic rejection still happens synchronously at submit time, with the same status codes and bodies as the sync endpoint: bad or cross-tenant UUIDs (404), missing fields (400), an embedding deployment instead of a chat deployment (400 deployment_modality_mismatch), no tenant LLM key on the deployment (400), text-layer requirement (422), and deployment-limit prechecks (400). A job is only created for a request that could plausibly succeed — you'll never poll your way to a "you forgot the file" error.

On acceptance you get 202 Accepted:

{
  "success": true,
  "job_id": "58c6ebca-7546-45b6-957c-0ecbdf91a9a5",
  "status": "queued",
  "status_url": "/api/extract-jobs/58c6ebca-7546-45b6-957c-0ecbdf91a9a5",
  "suggested_poll_secs": 5,
  "expires_at": "2026-08-09T00:21:00.000Z"
}

The Location header carries the same status_url. The uploaded document is stored server-side for the job's lifetime and deleted when the job expires (expires_at, 48 hours after submission).

Poll: GET /api/extract-jobs/:id

Same bearer auth. Polling is a pure read — it never affects the job, so poll as often as you like (every suggested_poll_secs is plenty; the response also sets a Retry-After header while the job is in flight). Jobs are tenant-scoped: another tenant's key gets a 404, not your job.

The response is always 200 OK with a status field — branch on it:

statusMeaningExtra fields
queuedAccepted, waiting for a worker. Pickup takes a few seconds while a worker is warm; if the worker pool has scaled to zero, allow a minute or two for one to spin up.suggested_poll_secs
runningA worker is extracting. Large vision documents can stay here for many minutes.suggested_poll_secs
succeededDone. Terminal.result — same { data, usage, cost, durationMs } shape as the sync 200 body
failedDone, unsuccessfully. Terminal.error — see below

Every response also carries job_id, attempts, created_at, completed_at (null until terminal), and expires_at.

Succeeded:

{
  "job_id": "58c6ebca-7546-45b6-957c-0ecbdf91a9a5",
  "status": "succeeded",
  "attempts": 1,
  "created_at": "2026-08-07T00:21:00.000Z",
  "completed_at": "2026-08-07T00:26:12.000Z",
  "expires_at": "2026-08-09T00:21:00.000Z",
  "result": {
    "data": { "effective_date": "2024-03-01" },
    "usage": { "inputTokens": 48210, "outputTokens": 812, "totalTokens": 49022, "cachedInputTokens": 0 },
    "cost": { "inputCost": 0.14463, "outputCost": 0.01218, "totalCost": 0.15681, "inputPricePerMillion": 3.0, "outputPricePerMillion": 15.0 },
    "durationMs": 291384
  }
}

Failed:

{
  "job_id": "58c6ebca-7546-45b6-957c-0ecbdf91a9a5",
  "status": "failed",
  "attempts": 3,
  "created_at": "2026-08-07T00:21:00.000Z",
  "completed_at": "2026-08-07T00:29:41.000Z",
  "expires_at": "2026-08-09T00:21:00.000Z",
  "error": {
    "type": "upstream_unavailable",
    "retryable": true,
    "message": "Google Vertex AI request failed (...): request to ... failed, reason: read ECONNRESET"
  }
}

job_timeout — the async-only error

job_timeout means the job didn't reach a normal conclusion within its time budget. It covers three distinct situations, and retryable tells you which side of the line you're on:

  • The extraction genuinely exceeded its per-attempt budget (15 minutes of model time) — retryable: false. A document that needs more than that will need it again; resubmitting won't change the outcome.
  • The worker processing the job died mid-run (replica restart, out-of-memory kill) — retryable: true. Resubmit; a healthy worker will very likely finish it.
  • The job was never picked up within an hour of submission — retryable: true. This indicates a service-side problem; resubmit, and if it recurs, something is wrong on our end.

Job expiry

A job row — result included — lives for 48 hours after submission, then it and its stored document are purged. Polling a purged (or never-existent, or another tenant's) job id returns 404 with Extraction job not found (unknown id, different tenant, or past its retention window). Collect your results within the window; the extraction itself is not re-runnable from an expired job.

Walkthrough

  1. 1. Submit the job

    Identical to the sync call except for the path:

    curl -X POST https://evals.thelibrari.com/api/extract-async \
      -H "Authorization: Bearer $LIBRARI_EXTRACT_KEY" \
      -F "file=@/path/to/very-long-contract.pdf" \
      -F "requestSchemaVersionId=20448b1b-87d1-4e99-b521-80360b676035" \
      -F "modelDeploymentId=9f7e3c4d-b8a2-4e1f-8c6d-2a5b9c4d3e7f"

    A 202 means the document passed every precheck and is queued. Anything else is the same synchronous error you'd get from /api/extract — handle it the same way.

  2. 2. Poll until terminal

    TypeScript / Node 20+:

    async function pollExtractJob(jobId: string): Promise<Record<string, unknown>> {
      const url = `https://evals.thelibrari.com/api/extract-jobs/${jobId}`
      const headers = { Authorization: `Bearer ${process.env.LIBRARI_EXTRACT_KEY!}` }
    
      for (;;) {
        const res = await fetch(url, { headers })
        if (res.status === 404) throw new Error("Job unknown or expired")
        const body = await res.json()
    
        if (body.status === "succeeded") return body.result.data
        if (body.status === "failed") {
          const err = new Error(`Extraction failed [${body.error.type}]: ${body.error.message}`)
          ;(err as any).retryable = body.error.retryable
          throw err
        }
        // queued or running — wait and go again
        await new Promise((r) => setTimeout(r, (body.suggested_poll_secs ?? 5) * 1000))
      }
    }

    Give the whole poll loop a generous deadline on your side — a worst-case job (15-minute attempt, retried) can take ~45 minutes before the server declares it failed. If you need a tighter bound, stop polling early; the job finishes (and stays collectable for 48h) regardless.

  3. 3. Handle a failed job

    Branch on error.retryable, exactly like the sync endpoint:

    • retryable: true — resubmit the document as a new job (there's no in-place retry of a failed job). The server already retried transient provider errors up to 3 attempts before giving up, so add your own backoff before resubmitting.
    • retryable: false — terminal for this document + schema + deployment combination. The type tells you what to change: a different deployment, a smaller document, an OCR pass first.

On this page