HTTP Clients And APIs

Long-Running API Work And Failure Handling

Some API requests cannot finish while the caller waits. Report exports, video processing, large imports, payment reconciliation, bulk email sends, and third-party synchronisation may take seconds, minutes, or hours.

A good API does not leave the HTTP request hanging forever. It accepts the work, returns a job ID, lets the client check progress, and records failures clearly enough for support and developers to act.

Returning 202 Accepted

202 Accepted means the server accepted the request, but processing has not finished yet. The response should include a job identifier and a URL where the client can check status.

PHP example
<?php

declare(strict_types=1);

function acceptedJobResponse(string $jobId): array
{
    return [
        'status' => 202,
        'headers' => [
            'Content-Type' => 'application/json',
            'Location' => '/v1/jobs/' . $jobId,
        ],
        'body' => [
            'data' => [
                'id' => $jobId,
                'status' => 'queued',
            ],
        ],
    ];
}

print_r(acceptedJobResponse('job_123'));

// Prints:
// [status] => 202
// [Location] => /v1/jobs/job_123
// [status] => queued

Do not return 201 Created unless the resource has actually been created. A queued job is not the same as completed work.

Job states

Job states should be predictable. A simple workflow might be:

  • queued: accepted and waiting for a worker.
  • running: currently being processed.
  • succeeded: finished successfully.
  • failed: stopped because of a non-retryable error or exhausted retries.
  • cancelled: deliberately stopped by a user or system.
PHP example
<?php

declare(strict_types=1);

function canMoveJob(string $from, string $to): bool
{
    $allowed = [
        'queued' => ['running', 'cancelled'],
        'running' => ['succeeded', 'failed', 'cancelled'],
        'failed' => [],
        'succeeded' => [],
        'cancelled' => [],
    ];

    return in_array($to, $allowed[$from] ?? [], true);
}

var_dump(canMoveJob('queued', 'running'));
var_dump(canMoveJob('succeeded', 'running'));

// Prints:
// bool(true)
// bool(false)

Avoid vague states such as done when the client needs to distinguish success from failure.

Status endpoint

The status endpoint should give the client enough information to decide what to do next, without exposing internal stack traces.

PHP example
<?php

declare(strict_types=1);

function jobStatusResponse(array $job): array
{
    $body = [
        'data' => [
            'id' => $job['id'],
            'status' => $job['status'],
            'created_at' => $job['created_at'],
            'updated_at' => $job['updated_at'],
        ],
    ];

    if ($job['status'] === 'failed') {
        $body['data']['error'] = [
            'code' => $job['error_code'] ?? 'job_failed',
            'message' => $job['public_error'] ?? 'The job could not be completed.',
        ];
    }

    return ['status' => 200, 'body' => $body];
}

$response = jobStatusResponse([
    'id' => 'job_123',
    'status' => 'failed',
    'created_at' => '2026-05-28T12:00:00Z',
    'updated_at' => '2026-05-28T12:05:00Z',
    'error_code' => 'export_source_unavailable',
    'public_error' => 'The export source is temporarily unavailable.',
]);

print_r($response);

// Prints:
// [status] => failed
// [code] => export_source_unavailable

Internal exception messages belong in logs, not in public API responses.

Worker retries

Background workers need retry rules just like HTTP clients. Temporary failures can be retried. Invalid input, permission failures, or missing records usually need a clear failed state.

PHP example
<?php

declare(strict_types=1);

function nextJobAction(string $failureType, int $attempt, int $maxAttempts): string
{
    $temporary = in_array($failureType, ['timeout', 'rate_limited', 'service_unavailable'], true);

    if ($temporary && $attempt < $maxAttempts) {
        return 'retry';
    }

    return 'fail';
}

echo nextJobAction('timeout', 1, 3) . PHP_EOL;
echo nextJobAction('invalid_input', 1, 3) . PHP_EOL;
echo nextJobAction('timeout', 3, 3) . PHP_EOL;

// Prints:
// retry
// fail
// fail

Retries should include attempt counts, backoff, and logs. Infinite retries are just hidden failures.

Idempotency and duplicate jobs

Long-running work often needs idempotency. If a client submits the same export request twice after a timeout, the API may need to return the existing job instead of creating another expensive job.

Use an idempotency key, a unique job request hash, or a business constraint such as "one active export per user and report type", depending on the product requirement.

Long-running endpoints also need admission control. Accepting every job is not kinder to clients if the queue is already hours behind or the account has exceeded a quota. A good API can reject new work with a clear 429 Too Many Requests, 409 Conflict, or 503 Service Unavailable depending on the reason. The response should tell the client whether retrying later makes sense and, where possible, include Retry-After or a product-specific limit message.

Admission control should happen before durable acceptance. Once the API returns 202, the client can reasonably expect the job to be tracked. If the system later drops the job because the queue was full, that is a contract failure. Check account limits, request size, worker capacity signals, duplicate active jobs, and disabled feature flags before creating the job record. If the checks are expensive, make them explicit in the workflow instead of hiding them behind a job that fails instantly.

Priorities and fairness matter as the system grows. A single customer should not be able to fill the queue with large exports that delay every other customer. Store enough metadata to schedule fairly: account, job type, estimated size, submitted time, and priority class. Workers can then process small interactive jobs separately from bulk jobs, or enforce per-account concurrency limits.

Cancellation and time limits

Some jobs should be cancellable. Others may need a maximum runtime so stuck work does not run forever. A worker should update heartbeat or progress information if the job is expected to run for a long time.

Progress can be exact, such as 42 of 1000 rows processed, or coarse, such as queued, processing, and finalising. Do not fake precision if the system cannot measure it.

Acceptance Must Be Durable

A 202 Accepted response means the server has accepted responsibility for the work, not merely that it put something in memory. If the process crashes immediately after responding, the job should still exist.

Persist the job record before returning 202. If queueing is separate from job creation, make the database change and queue publication reliable. Many systems create a job row and let a worker poll or consume an outbox event. If durable storage fails, return a retryable server error rather than pretending the job exists.

The acceptance response should include a stable job ID, status URL, initial state, and any polling guidance. A Location header is useful because generic clients and support tools can find the job resource.

Job Resource Shape

The status resource is the client's view of the work. It should not expose internal worker names or stack traces, but it should be actionable.

A useful status response may include:

  • job ID
  • status
  • created and updated timestamps
  • submitted request summary
  • progress where measurable
  • result link when succeeded
  • public error code when failed
  • retry or cancellation links where supported
  • request or correlation ID for support
PHP example
<?php

declare(strict_types=1);

function statusBody(array $job): array
{
    $data = [
        'id' => $job['id'],
        'status' => $job['status'],
        'created_at' => $job['created_at'],
        'updated_at' => $job['updated_at'],
    ];

    if ($job['status'] === 'running') {
        $data['progress'] = ['processed' => $job['processed'], 'total' => $job['total']];
    }

    if ($job['status'] === 'succeeded') {
        $data['result_url'] = '/v1/exports/' . rawurlencode($job['result_id']);
    }

    return ['data' => $data];
}

$response = statusBody([
    'id' => 'job_123',
    'status' => 'running',
    'created_at' => '2026-06-10T09:00:00Z',
    'updated_at' => '2026-06-10T09:01:00Z',
    'processed' => 40,
    'total' => 100,
]);

echo json_encode($response, JSON_THROW_ON_ERROR) . PHP_EOL;

// Prints:
// {"data":{"id":"job_123","status":"running","created_at":"2026-06-10T09:00:00Z","updated_at":"2026-06-10T09:01:00Z","progress":{"processed":40,"total":100}}}

Do not report fake exact percentages for work that cannot be measured. Coarse states are better than dishonest precision.

Polling Without Hammering

A status endpoint can be polled, but clients need guidance. The acceptance or status response can include Retry-After, cache headers, or a documented minimum interval. Clients should back off when the job remains queued or running.

Avoid designs where thousands of clients poll every second for jobs that take minutes. Options include coarser polling, webhooks on completion, server-sent events for interactive products, or notification channels. Even with webhooks, keep the status endpoint; webhooks can be missed and support teams need a source of truth.

The status endpoint should be cheap and indexed by job ID and owner. Authorization matters: one user must not read another user's export status.

Idempotent Submission

Submitting the same long-running request twice can waste workers and create conflicting outputs. Use idempotency keys or business uniqueness rules.

For exports, a reasonable rule might be one active export per user, report type, and parameter hash. If the same request arrives again while the first is active, return the existing job. If the request differs, create a new job or reject according to product rules.

PHP example
<?php

declare(strict_types=1);

function jobRequestHash(array $request): string
{
    ksort($request);

    return hash('sha256', json_encode($request, JSON_THROW_ON_ERROR));
}

$first = jobRequestHash(['report' => 'orders', 'from' => '2026-06-01', 'to' => '2026-06-10']);
$second = jobRequestHash(['to' => '2026-06-10', 'from' => '2026-06-01', 'report' => 'orders']);

echo $first === $second ? 'same job request' : 'different job request';
echo PHP_EOL;

// Prints:
// same job request

Store the hash with account and operation scope. Do not let one customer collide with another customer's export.

Cancellation Semantics

Cancellation is a state transition, not a process kill button. A cancellable job needs a public request such as POST /v1/jobs/{id}/cancel, authorization checks, and worker cooperation.

A worker should check cancellation between units of work and stop safely. Some actions cannot be undone once started, such as sending an email or charging a payment. In those cases, cancellation may mean "stop future work" rather than "rollback everything."

Document status results: cancelling a queued job may immediately become cancelled, while cancelling a running job may become cancelling until the worker reaches a safe point.

Worker Leases And Stuck Jobs

Long-running jobs need ownership rules. A worker can claim a job with a lease or visibility timeout, periodically heartbeat, and release or renew the lease. If the worker dies, another worker can recover the job after the lease expires.

Attempt counts should be stored durably. A retry after worker crash should not reset the job to infinite first attempts. Keep last failure category, last error time, and enough safe context for diagnosis.

When attempts are exhausted, move to failed or dead_letter with a public error code and an internal log reference. Silent stuck jobs are worse than visible failures.

Results, Retention, And Cleanup

A completed job often points to a result: a file, report, import summary, or created resource. Define how long the result is retained, how authorization is checked, and what happens after expiry.

A status endpoint for an expired job can return 410 Gone or a failed/expired state depending on the API convention. Document the policy. Support teams need to know whether a missing result is a bug or expected cleanup.

Do not delete audit records too aggressively. A result file may expire in seven days, while the job metadata may need to remain for billing, support, or compliance.

Webhook Completion Callbacks

For external clients, completion webhooks can reduce polling. The job submission request can include a callback URL only if the product is prepared to validate, store, sign, retry, and support outbound webhook delivery.

Do not trust arbitrary callback URLs without SSRF protections and allow-list policies where appropriate. Signing, retries, dead-letter handling, and customer-visible delivery logs are required if callbacks become part of the public contract.

A webhook is a notification, not the source of truth. Clients should still be able to fetch the job status by ID.

What To Check

Before moving on, make sure you can:

  • return 202 Accepted only after durable job acceptance
  • design clear job states and legal transitions
  • build a status response that is useful but does not leak internals
  • give polling guidance without overloading the API
  • prevent duplicate long-running work with idempotency or request hashes
  • decide which worker failures retry and which fail
  • model cancellation as a cooperative state transition
  • recover from worker crashes with leases or visibility timeouts
  • define result retention and expiry behavior
  • explain how completion webhooks fit without replacing status resources

Practice

Practice: Model A Long-Running Job

Write a PHP example that models accepting a long-running API job and checking its status.

Requirements

  • Return 202 when a job is accepted.
  • Include a job ID and Location status URL.
  • Support job states such as queued, running, succeeded, and failed.
  • Include a status response for a failed job with a public error code and message.
  • Add a retry decision for temporary worker failures.
  • Include examples for accepted, failed status, retryable failure, and exhausted retries.
Show solution

This solution separates the initial acceptance response from the later status response.

PHP example
<?php

declare(strict_types=1);

function acceptJob(string $jobId): array
{
    return [
        'status' => 202,
        'headers' => [
            'Location' => '/v1/jobs/' . $jobId,
            'Content-Type' => 'application/json',
        ],
        'body' => [
            'data' => [
                'id' => $jobId,
                'status' => 'queued',
            ],
        ],
    ];
}

function jobStatus(array $job): array
{
    $data = [
        'id' => $job['id'],
        'status' => $job['status'],
    ];

    if ($job['status'] === 'failed') {
        $data['error'] = [
            'code' => $job['error_code'],
            'message' => $job['public_error'],
        ];
    }

    return ['status' => 200, 'body' => ['data' => $data]];
}

function workerFailureAction(string $failureType, int $attempt, int $maxAttempts): string
{
    $temporary = in_array($failureType, ['timeout', 'rate_limited', 'service_unavailable'], true);

    if ($temporary && $attempt < $maxAttempts) {
        return 'retry';
    }

    return 'fail';
}

$accepted = acceptJob('job_123');
$failed = jobStatus([
    'id' => 'job_123',
    'status' => 'failed',
    'error_code' => 'source_unavailable',
    'public_error' => 'The export source is temporarily unavailable.',
]);

echo $accepted['status'] . ' ' . $accepted['headers']['Location'] . PHP_EOL;
echo $failed['body']['data']['status'] . ' ' . $failed['body']['data']['error']['code'] . PHP_EOL;
echo workerFailureAction('timeout', 1, 3) . PHP_EOL;
echo workerFailureAction('timeout', 3, 3) . PHP_EOL;

// Prints:
// 202 /v1/jobs/job_123
// failed source_unavailable
// retry
// fail

This is the API contract clients need: fast acceptance, a stable status URL, clear states, and predictable failure handling.

Practice: Design A Job Status Resource

Design a status response for a report export job.

Task

Write a PHP function that builds a status response for queued, running, succeeded, and failed jobs. Include:

  • job ID
  • status
  • timestamps
  • progress only when running
  • result URL only when succeeded
  • public error only when failed

Demonstrate a running job and a succeeded job.

Show solution

The response includes fields only when they apply to the current state.

PHP example
<?php

declare(strict_types=1);

function jobStatusBody(array $job): array
{
    $data = [
        'id' => $job['id'],
        'status' => $job['status'],
        'created_at' => $job['created_at'],
        'updated_at' => $job['updated_at'],
    ];

    if ($job['status'] === 'running') {
        $data['progress'] = ['processed' => $job['processed'], 'total' => $job['total']];
    }

    if ($job['status'] === 'succeeded') {
        $data['result_url'] = $job['result_url'];
    }

    if ($job['status'] === 'failed') {
        $data['error'] = ['code' => $job['error_code'], 'message' => $job['public_error']];
    }

    return ['data' => $data];
}

$running = jobStatusBody([
    'id' => 'job_123',
    'status' => 'running',
    'created_at' => '2026-06-10T09:00:00Z',
    'updated_at' => '2026-06-10T09:01:00Z',
    'processed' => 40,
    'total' => 100,
]);

$succeeded = jobStatusBody([
    'id' => 'job_123',
    'status' => 'succeeded',
    'created_at' => '2026-06-10T09:00:00Z',
    'updated_at' => '2026-06-10T09:05:00Z',
    'result_url' => '/v1/exports/export_123',
]);

echo $running['data']['status'] . ' ' . $running['data']['progress']['processed'] . PHP_EOL;
echo $succeeded['data']['status'] . ' ' . $succeeded['data']['result_url'] . PHP_EOL;

// Prints:
// running 40
// succeeded /v1/exports/export_123

The API avoids fake progress for queued jobs and avoids exposing internal exception details for failed jobs.

Practice: Handle Duplicate Export Request

Model duplicate submission for a long-running export endpoint.

Task

Write a function that accepts account ID, report type, request parameters, and an array of active jobs. It should:

  • hash the normalized request parameters
  • return an existing active job when account, report type, and request hash match
  • create a new queued job otherwise
  • keep different accounts isolated

Demonstrate an exact duplicate and a different account.

Show solution

The request hash is scoped with account and report type so unrelated customers do not collide.

PHP example
<?php

declare(strict_types=1);

function requestHash(array $parameters): string
{
    ksort($parameters);

    return hash('sha256', json_encode($parameters, JSON_THROW_ON_ERROR));
}

function submitExport(string $accountId, string $reportType, array $parameters, array &$jobs): array
{
    $hash = requestHash($parameters);

    foreach ($jobs as $job) {
        if (
            $job['account_id'] === $accountId
            && $job['report_type'] === $reportType
            && $job['request_hash'] === $hash
            && in_array($job['status'], ['queued', 'running'], true)
        ) {
            return ['created' => false, 'job' => $job];
        }
    }

    $job = [
        'id' => 'job_' . (count($jobs) + 1),
        'account_id' => $accountId,
        'report_type' => $reportType,
        'request_hash' => $hash,
        'status' => 'queued',
    ];

    $jobs[] = $job;

    return ['created' => true, 'job' => $job];
}

$jobs = [];
$first = submitExport('acct_1', 'orders', ['from' => '2026-06-01', 'to' => '2026-06-10'], $jobs);
$duplicate = submitExport('acct_1', 'orders', ['to' => '2026-06-10', 'from' => '2026-06-01'], $jobs);
$otherAccount = submitExport('acct_2', 'orders', ['from' => '2026-06-01', 'to' => '2026-06-10'], $jobs);

echo ($first['created'] ? 'created' : 'existing') . ' ' . $first['job']['id'] . PHP_EOL;
echo ($duplicate['created'] ? 'created' : 'existing') . ' ' . $duplicate['job']['id'] . PHP_EOL;
echo ($otherAccount['created'] ? 'created' : 'existing') . ' ' . $otherAccount['job']['id'] . PHP_EOL;

// Prints:
// created job_1
// existing job_1
// created job_2

A production implementation should enforce the active-job uniqueness rule atomically in durable storage.