API development

Build an asynchronous API with a durable job contract

A useful asynchronous API gives each accepted operation a durable status resource. Build the job-state core, inspect its persisted checkpoints and define what clients observe when completion races with cancellation.

Acceptance
Commit the job before acknowledging it with HTTP 202
Executable core
Local SQLite persistence and sequential state transitions
Cancellation
The first committed transition determines the sample's result; external side effects are outside the model
Paper-sculpture intake station, queued blue cards and a separate report workbench with an amber status-check loop.
Conceptual illustration of accepting work and inspecting its progress separately.

Start with the promise behind 202

Return 202 Accepted when the server has accepted work that has not finished. Give the client a durable way to inspect that work. Do not make the client infer success from an open connection, a spinner or the absence of an error.

RFC 9110 makes clear that acceptance does not guarantee eventual completion. The stronger promise in this tutorial is an application design choice: once our endpoint acknowledges a job, a committed job record exists and remains inspectable under the published retention policy.

You will build the persistence and state-transition core for a fictional report API. The downloadable program uses a local SQLite database and exercises job recovery after reopening that database. HTTP messages below describe the proposed interface. The program does not start a web server, authenticate users or run a distributed queue.

Create the job before acknowledging it

Validate the request, resolve the tenant from trusted authentication context and decide whether the caller may create this report. Then insert the job in the queued state and commit. Return the acknowledgment only after that commit succeeds.

Use a stable job URL in the response's Location header. In this example, Location: /jobs/job-a identifies the status resource and Retry-After: 3 asks the client to wait three seconds before polling. That interval is an illustrative contract value, not a measured completion estimate. Microsoft documents this arrangement in its asynchronous request-reply pattern.

Illustrative procedure
{"job_id":"job-a","state":"queued","status_url":"/jobs/job-a"}

The job ID does not replace an idempotency key. If the acknowledgment is lost, the client may repeat the creation request. Define whether the same logical request returns its existing job or creates another one. The idempotency walkthrough covers request identity and uncertain responses; this tutorial concentrates on the lifecycle after a job exists.

Treat the status as a small state machine. A worker claims queued work by conditionally changing it to running. It must check whether that mutation affected a row. A second worker that finds no eligible row has not acquired the job.

Proposed job states and legal transitions
Current statePermitted next stateActor and condition
queuedrunningWorker successfully claims the job
queuedcanceledAuthorized cancellation arrives before claim
runningsucceeded or failedWorker records a terminal result
runningcancel_requestedAuthorized cancellation requests a stop
cancel_requestedcanceledWorker confirms it stopped and completed required cleanup
succeeded, failed or canceledno transitionTerminal records remain stable
Queued jobs can run or be canceled. Running jobs can succeed, fail or receive a cancellation request. Cleanup moves cancel_requested to canceled; terminal states do not transition.
Figure 1. Proposed job lifecycle. Cancellation requested and cancellation completed are distinct states. View full-size figure.

Store a result reference only when the corresponding success transition commits. In the local example, the result is a small value in the same database row. Real report files may live elsewhere. If you upload a file first and then fail to commit success, you need an orphan-cleanup rule. If you publish success before the file is readable, clients can observe a completed job with a missing result.

Keep the job's current state separate from its attempt history. A retryable processing failure may produce another worker attempt while the customer still sees an active job. Do not turn every transient exception into a terminal failed status unless that is the actual retry policy.

Run the state core and inspect the checkpoints

Download job_core.py, save it under that filename and run python3 job_core.py in an empty directory. Python must include its standard sqlite3 module. The script creates its test database inside a temporary directory and writes checkpoints.json into your working directory. First, inspect the reopened_queued_job checkpoint. The program commits the accepted job, closes the connection and opens the database again. The job must still be queued. This is a persistence check across connection reopening, not a power-loss test or proof that a particular storage configuration survives hardware failure.

Next, inspect second_claim. It is false because the first conditional claim already moved the job to running. The relevant mutation has this shape:

Illustrative procedure
UPDATE jobs SET state = 'running'
WHERE id = ? AND tenant = ? AND state = 'queued';

Finally, inspect both cancellation orders described below. The program uses sequential transactions to exercise each possible order. It does not launch concurrent threads or measure database contention. SQLite's transaction documentation explains the transaction boundary and its single-writer constraints. The sample is intentionally a small local teaching core.

Give cancellation an observable outcome

Cancellation is a request to stop work.

It does not prove that every side effect has been undone. While a job is running, this interface moves it to cancel_requested. The worker changes it to canceled only after it reaches a safe stop point and completes the cleanup required by that job type.

The sample keeps the completion value and state change in one conditional mutation. When cancellation wins first, a later success mutation requiring running affects no row. When success wins first, cancellation leaves the terminal succeeded record unchanged. Return that actual terminal state to the client instead of displaying a misleading "Canceled" toast.

Cancel-first order blocks the later success update and ends canceled with no result. Success-first order retains succeeded with report-b when cancellation arrives.
Figure 2. Executed sequential transaction orders in job_core.py. This checks the local result record, not reversal of external side effects. View full-size figure.

This ordering protects the modeled database result. It cannot unsend an email or reverse an external side effect that happened before the state check. Put irreversible work behind an explicit boundary and define whether cancellation remains available beyond it. The UI should explain the behavior the worker can actually provide.

Poll the status resource without hiding failures

Return a successful status read as 200 OK with the current job state, even when the job itself has failed. An HTTP error fetching the status and a terminal processing failure are different facts. A failed job body should include a stable error code and a safe explanation the caller can act on.

Illustrative procedure
{"job_id":"job-a","state":"failed","error":{"code":"INPUT_UNAVAILABLE","message":"The selected input is no longer available."}}

For active jobs, follow the advertised polling delay, add client jitter when many clients can poll together and stop polling on a terminal state or a client-side deadline. A client deadline stops waiting. It does not automatically cancel the server job. Avoid publishing a progress percentage unless the denominator is defined. "Processing 240 of 800 validated rows" is interpretable; "82% complete" based on elapsed time may not be. These numbers illustrate two ways to describe progress, not results from the program.

Authorize every status, result and cancellation request against the job's tenant and the caller's current permissions. A hard-to-guess identifier is useful defense in depth, but it is not an access policy. Use explicit private cache behavior for sensitive job responses and prevent shared caches from serving one customer's status to another.

Connect the worker and define the end of retention

The tutorial leaves worker dispatch outside its executable core. A committed job that never reaches a worker can remain queued forever. One design lets workers scan the durable job table. Another publishes dispatch intent through a transactional outbox. Choose the dispatch mechanism together with its retry, ownership and stuck-job recovery rules.

Specify retention independently for job metadata and generated files. The status response can expose a result expiry time while retaining enough terminal metadata to explain that the file is no longer downloadable. After metadata itself expires, define the response clients receive and how support can correlate a reported job ID with permitted operational records.

Before exposing the endpoint, add these transport-level checks around the executable core:

  • Rejected input never creates a job.
  • Unauthorized polling reveals no job data.
  • A lost creation response follows the idempotency policy.
  • Polling obeys the advertised delay.
  • An expired result does not look like a still-running job.

These are integration requirements for your application. The downloadable program does not perform them.

Sources

Documentation checked .

  1. RFC 9110: 202 Accepted
  2. Microsoft: asynchronous request-reply pattern
  3. SQLite: transactions