Asynchronous writes (202 Accepted / 303 See Other)¶
JSON:API 1.1 describes an asynchronous-processing
lifecycle: a server that cannot finish a write within the request accepts it with a
202 Accepted, points the client at a job resource to poll (a Content-Location
header, optionally a Retry-After hint), and — once the work completes — answers a
GET on that job resource with 303 See Other, redirecting to the resource the
operation produced.
The bundle exposes this as a thin seam: your persister decides to go async and
returns a marker; the handler renders the spec-correct 202. How you queue the work
is your choice — the recipe below uses Symfony Messenger,
but nothing about Messenger is baked in.
Every part of this lifecycle is reflected in the generated OpenAPI document via the
per-operation response declarations:
the write advertises the 202, and the job type's fetch advertises the 303. The
examples/music-catalog-symfony app wires this as the catalog-exports (always-async
create) / export-jobs (fetch-one 303 completion) pair, and the Laravel package
projects a byte-identical document for it.
Accepting the write — AcceptedForProcessing¶
A DataPersister that dispatches a write instead of committing it
returns an AcceptedForProcessing from create() (or update()) in place of the
persisted entity. The CrudOperationHandler renders it as a 202
rather than the usual 201/200:
use haddowg\JsonApiBundle\DataPersister\AcceptedForProcessing;
use haddowg\JsonApiBundle\DataPersister\DataPersisterInterface;
use Symfony\Component\Messenger\MessageBusInterface;
final class AsyncArticlePersister implements DataPersisterInterface
{
public function __construct(private MessageBusInterface $bus) {}
public function supports(string $type): bool
{
return $type === 'articles';
}
public function instantiate(string $type): object
{
return new Article();
}
public function create(string $type, object $entity): object
{
// Hand the work off to the queue instead of persisting inline.
$jobId = $this->bus->dispatch(new CreateArticle($entity))
->last(\Symfony\Component\Messenger\Stamp\HandledStamp::class)
?->getResult();
// Point the client at a job resource it can poll for completion.
return AcceptedForProcessing::poll('https://example.test/jobs/' . $jobId)
->withJob(new Job($jobId, 'queued'), 'jobs')
->withRetryAfter(30);
}
// update()/delete()/mutateRelationship() as usual…
}
The response is a 202:
HTTP/1.1 202 Accepted
Content-Type: application/vnd.api+json
Content-Location: https://example.test/jobs/9f3b
Retry-After: 30
{ "data": { "type": "jobs", "id": "9f3b", "attributes": { "status": "queued" } } }
AcceptedForProcessing::poll($url)sets theContent-Location— the URL the client polls.->withJob($job, 'jobs')renders$jobas the202body through thejobstype's registered serializer. Omit it (or use->withMeta([...])) for a meta-only status document.->withRetryAfter(30)setsRetry-Afterin delta-seconds; a\DateTimeInterfaceis emitted as an HTTP-date instead.
The jobs type is an ordinary JSON:API type — register a serializer for it (a
standalone #[AsJsonApiSerializer(type: 'jobs')], or a full resource if you want its
own endpoints). Persist the job wherever your queue tracks state.
Declare the 202 on the resource so the document advertises it — a maybe-async write
lists both the sync and the async status:
use haddowg\JsonApi\OpenApi\Metadata\{Accepted, Created};
#[AsJsonApiResource(create: [new Created(), new Accepted('jobs')])]
final class ArticleResource extends AbstractResource { /* … */ }
An always-async type declares create: [new Accepted('jobs')] (a 202 only). See
per-operation response declarations.
Reporting completion — 303 See Other¶
The spec models completion as a GET on the job's own URL: 200 (the job resource)
while the work runs, then 303 See Other to the produced resource once it is done.
The bundle drives that from the jobs type's fetch-one — implement
haddowg\JsonApi\Resource\ResolvesCompletionRedirect on the job resource (or its
serializer) and declare fetchOne: [new Ok(), new SeeOther()] so the 303 is in the
document:
use haddowg\JsonApi\OpenApi\Metadata\{Ok, SeeOther};
use haddowg\JsonApi\Resource\AbstractResource;
use haddowg\JsonApi\Resource\ResolvesCompletionRedirect;
use haddowg\JsonApiBundle\Attribute\AsJsonApiResource;
#[AsJsonApiResource(readOnly: true, fetchOne: [new Ok(), new SeeOther()])]
final class JobResource extends AbstractResource implements ResolvesCompletionRedirect
{
// Done → the produced resource's URL (the fetch renders 303); still running → null (200).
public function completionLocation(object $entity): ?string
{
\assert($entity instanceof Job);
return $entity->isDone() ? 'https://example.test/articles/' . $entity->createdId : null;
}
public function fields(): array { /* id + status … */ }
}
GET /jobs/9f3b → 200 (still running — the job resource)
GET /jobs/9f3b → 303 Location: https://example.test/articles/42 (done)
Or via a custom action. When completion isn't a plain job fetch — a side-effecting
POST, a distinct result endpoint — model it as a custom action
declaring responds: [new Accepted('jobs'), new SeeOther()], so the document
advertises both the still-running 202 and the completion 303:
use haddowg\JsonApi\OpenApi\Metadata\{Accepted, SeeOther};
use haddowg\JsonApi\Response\AcceptedResponse;
use haddowg\JsonApi\Response\SeeOtherResponse;
use haddowg\JsonApiBundle\Action\ActionContext;
use haddowg\JsonApiBundle\Action\ActionHandlerInterface;
use haddowg\JsonApiBundle\Attribute\AsJsonApiAction;
#[AsJsonApiAction(type: 'jobs', path: 'result', methods: ['GET'], responds: [new Accepted('jobs'), new SeeOther()])]
final class JobResult implements ActionHandlerInterface
{
public function handle(ActionContext $context): AcceptedResponse|SeeOtherResponse
{
$job = $context->entity();
\assert($job instanceof Job);
// Still running → 202 again; done → redirect to the produced resource.
return $job->isDone()
? $context->seeOther('https://example.test/articles/' . $job->createdId)
: $context->accepted('https://example.test/jobs/' . $job->id)->withRetryAfter(30);
}
}
so a polling client sees 202 until the work finishes and then a single 303.
Notes¶
- Atomic Operations. An async accept cannot join an
Atomic Operations batch — it defers the write past the
batch's all-or-nothing commit — so a persister that returns
AcceptedForProcessinginside a batch fails that sub-operation (422) and the batch rolls back. Keep async types out of atomic batches. - Scope. The seam covers
create()andupdate().delete()returnsvoid, so an async delete is not expressible through it today. - Portability.
AcceptedResponse/SeeOtherResponseare core, framework-neutral response value objects, so the Laravel package produces byte-identical202/303responses over its own queue.