Skip to content

fix(api): return a JSON status when a polled Celery task is revoked - #4023

Open
polylane[bot] wants to merge 1 commit into
masterfrom
polylane/autofix/cwxmqin3do9b
Open

polylane[bot] wants to merge 1 commit into
masterfrom
polylane/autofix/cwxmqin3do9b

Conversation

@polylane

@polylane polylane Bot commented Nov 12, 2026 •

Copy link
Copy Markdown

Polling the status of a background task returned HTTP 500 when the task had already expired and Celery had revoked it, because the handler tried to encode Celery's revocation error as JSON. The Auto-Linker that polls this endpoint saw the request fail instead of a terminal state. A revoked task now returns a normal JSON status. Celery stores a TaskRevokedError instance in a revoked task's result, and the endpoint copied it into the response before checking the state, so json.dumps raised. The change emits progress meta only when it is a dict and reports REVOKED as a terminal state.

Production impact at ef3cff9: Revoked-task path returns JSON; production behavior does not regress

Fix walkthrough

Watch the fix walkthrough

Watch the fix explained (1:28). Recorded for commit ef3cff9a61db5f09253de831a7f7031f657c555d.

Full analysis
flowchart TD
  A["Auto-Linker client POSTs /api/find-refs"] --> B["find_refs_api_task enqueued with expires"]
  B --> C["Worker picks up a task after it expired"]
  C --> D["Celery marks REVOKED; result.info = TaskRevokedError"]
  B --> E["Client polls GET /api/async/task_id"]
  E --> F["async_task_status_api sets payload.meta = result.info"]
  F --> G["jsonResponse json.dumps raises TypeError"]
  G --> H["HTTP 500 observed"]
  F -. proposed fix .-> I["Include meta only when it is a dict; handle REVOKED as terminal"]
  I --> J["HTTP 200 with state REVOKED fixed"]
Loading
Detection evidence

Affected: int_1d11030e4001koijtk9pub43

Out of scope
  • The client retry loop in static/js/linker.v3/main.js still does not treat REVOKED as terminal and exhausts its retries as before. Not changed here.

Production impact

The change only replaces an unhandled 500 on an error path with a JSON response and narrows when meta is emitted. The SUCCESS and FAILURE responses are identical, and no new dependency, query, or load is added. The web service serves the affected route at steady traffic and has no other caller of the changed function.

Affected resources

  • web-production

Observability

  • trace.django.request.errors{service:web,resource_name:*async*}.as_count(): the HTTP 500 count for the status route; it should fall to zero for revoked tasks after deploy.
  • async_task_status_api:revoked (new structured log): one entry per revoked task served, at info level.
  • async_task_status_api:failed and async_task_status_api:pending: unchanged and still describe the other states.

Polylane assessed this pull request at ef3cff9.

Causal chain
  • Signal (metric): trace.django.request.errors{service:web,resource_name:async} - GET /api/async/<task_id> HTTP 500
  • Surfacing site: www.sefaria.org web service (service:web) at sefaria/views.py#async_task_status_api
  • Mechanism: For a REVOKED Celery task, AsyncResult.info is a TaskRevokedError instance and result.failed() is false, so the handler set payload.meta = result.info; jsonResponse's json.dumps raised TypeError and Django returned HTTP 500.
  • Producer: Sefaria-Project web application (sefaria/views.py), at sefaria/views.py#async_task_status_api
  • Trigger: find_refs_api_task is enqueued with expires=FIND_REFS_TASK_EXPIRES_SECONDS; a task that expires before a worker runs it is REVOKED, and the Auto-Linker client polls its task_id.
  • What happens to the failed unit today: jsonResponse (sefaria/client/util.py) requires JSON-serializable data and ends in json.dumps; a non-serializable value becomes an unhandled 500.
  • Cadence check: 598 error spans over the 6 hours to 2026-11-12T21:20Z in 47 2-minute buckets, latest 6 at 21:18Z; roughly 2,000/day.
  • Last observed: 2026-11-12T21:19:11Z
  • Blast radius: unknown other resource(s), unknown other tenant(s); data at risk: none named
  • Producer evidence:
    • sefaria/views.py#async_task_status_api on master fa5b70d: if result.info is not None and state not in ("SUCCESS", "FAILURE"): payload["meta"] = result.info, then a fall-through returning jsonResponse(payload, status=202) for REVOKED.
    • celery/backends/base.py#mark_as_revoked stores TaskRevokedError(reason) with state REVOKED; celery/result.py sets info = result, and failed()/successful() are true only for FAILURE/SUCCESS.
    • Datadog APM span 2026-11-12T21:19:11.264Z: status_code 500, view sefaria.views.async_task_status_api, route ^api/async/(?P<task_id>.+)$, referrer a third-party blog.
    • Local reproducer with the real handler and an AsyncResult in REVOKED state returned 500 and the exact TypeError before the fix, and 200 with a serializable body after it.
2 files changed (+108/-2)
  • sefaria/tests/async_task_status_test.py: added, +95/-0
  • sefaria/views.py: modified, +13/-2

View autofix


Generated by Polylane. You can ask follow-ups by mentioning @polylane in a comment. If you close this pull request without merging, add a comment that says why. Polylane reads it to make its next fixes better.

Co-authored-by: polylane[bot] <277585245+polylane[bot]@users.noreply.github.com>
@polylane polylane Bot added polylane severity:medium Polylane autofix severity: medium labels Nov 12, 2026
@polylane
polylane Bot requested review from YishaiGlasner and yodem November 12, 2026 21:21

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

polylane severity:medium Polylane autofix severity: medium

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant