Skip to content

feat(api): default /label and /predict to async polling, support ?wait=true for compat (#9)#16

Merged
oldhero5 merged 2 commits into
mainfrom
feat/async-job-polling
May 24, 2026
Merged

feat(api): default /label and /predict to async polling, support ?wait=true for compat (#9)#16
oldhero5 merged 2 commits into
mainfrom
feat/async-job-polling

Conversation

@oldhero5

Copy link
Copy Markdown
Owner

Closes #9.

Summary

Moves the blocking Celery .get(timeout=180) call out of the request path on POST /api/v1/label/preview — under load this was filling FastAPI's thread pool and stalling fast endpoints like /health. The endpoint now dispatches the SAM 3.1 playground task and returns 202 Accepted with {job_id, status, result_url} immediately; clients poll the new generic GET /api/v1/job/{job_id} until completion.

Migration guide

Existing API consumers that POSTed to /label/preview and received a synchronous PreviewResponse body should switch to the polling pattern: read job_id from the 202 envelope, then GET /api/v1/job/{job_id} until status == "completed" and read the body from result. For one release we keep the legacy behavior available behind ?wait=true (still bounded at the original 180 s timeout) so callers can migrate without breakage; the flag is documented as deprecated and will be removed in a future release. The UI helper previewPrompts() already polls under the hood, so React components that await previewPrompts(...) keep working unchanged.

Endpoints changed

  • POST /api/v1/label/preview — defaults to async (202 + {job_id, status, result_url}); legacy sync body available behind ?wait=true (deprecated).
  • GET /api/v1/job/{job_id}new. Generic Celery task polling surface; maps PENDING/RECEIVED/RETRY → queued, STARTED → running, SUCCESS → completed, FAILURE/REVOKED → failed, returns the task's return value in result on success.

The other endpoints listed in the issue's migration table either already returned 202 + job_id (e.g. /label, /label/exemplar, /predict/video for long videos, /comparisons/run) or run in-process with asyncio.to_thread rather than via Celery (/predict/image, /predict/sam, /predict/sam/video, /predict/video short-path) — converting those to a Celery polling pattern would be a much larger change beyond #9's stated scope. The /job/{job_id} endpoint is the building block for those future migrations.

Test plan

  • uv run pytest -q — full suite. Same baseline as main (14 failed / 7 skipped, all infra-dependent), +2 new passing tests.
  • tests/test_concurrent_label.py::test_label_storm_does_not_starve_health — 50 simultaneous /label POSTs against the ASGI app; asserts /health worst-case latency stays under 200 ms during the storm.
  • tests/test_concurrent_label.py::test_job_endpoint_maps_celery_states — verifies the four-state status mapping for GET /api/v1/job/{job_id}.
  • cd ui && npm run build — clean build, no TS or lint errors.
  • npx tsc --noEmit and npx eslint --max-warnings=0 src/api.ts — clean.
  • Docs updated: API overview now documents the polling pattern, the deprecated ?wait=true flag, and the new GET /api/v1/job/{job_id} endpoint. README example pipeline uses the polling pattern.
  • Manual smoke test: make build PROFILE=apple, bring up the stack, hit /api/v1/label/preview, confirm 202, poll, confirm result. (Skipped in this worktree — the Apple profile needs MLX on macOS hardware; deferred to local validation.)

🤖 Generated with Claude Code

oldhero5 and others added 2 commits May 24, 2026 16:23
…t=true for compat (#9)

Move the blocking `_dispatch_and_wait` Celery `.get(timeout=180)` out of
`POST /api/v1/label/preview`. The endpoint now defaults to dispatching the
SAM 3.1 playground task and returning `202 Accepted` with
`{job_id, status, result_url}`; clients poll the new
`GET /api/v1/job/{job_id}` until the task completes. `?wait=true` keeps
the legacy synchronous shape for one release.

The polling endpoint is generic: it maps Celery's PENDING / STARTED /
SUCCESS / FAILURE / REVOKED states into a four-value `queued | running |
completed | failed` enum and returns the task's return value in `result`
on success. UI callers route through a new `pollJob<T>()` helper in
`ui/src/api.ts`, so existing components (PlaygroundPage, LabelPage) still
`await previewPrompts(...)` and get a `PreviewResponse` back without
knowing about the polling.

A concurrency regression test (`tests/test_concurrent_label.py`) fires 50
simultaneous `/label` calls against the ASGI app and asserts `/health`
keeps responding under 200 ms during the storm.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@oldhero5
oldhero5 merged commit cdafd03 into main May 24, 2026
1 check passed
@oldhero5
oldhero5 deleted the feat/async-job-polling branch May 24, 2026 20:31
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Move blocking Celery .get(timeout=180) in /label and /serve to async polling

1 participant