PolicyEngine / PolicyEngine/policyengine-household-api
Gateway leaks Modal dispatch threads on hung control-plane calls; circuit breaker never opened during 2026-07-21 outage
Nobody has claimed this yet.
- Dominant language
- Python
- Stars
- 1
- Forks
- 3
- Avg merge
- 6h 34m
- Merged PRs (30d)
- 8
Description
Summary
On 2026-07-21 between ~01:02 and ~01:17 UTC, the production gateway returned 503 backend_unavailable for most /us/calculate requests on both channels. The Modal workers were healthy throughout (their logs show every request that reached them served with HTTP 200 in 1.5–4s, warm containers, min_containers=3 active). The outage was produced in the gateway's Modal dispatch path, and automatic failover to the Cloud Run fallback never triggered. Service was restored by a manual HOUSEHOLD_FAILOVER_FORCE_BACKEND=cloud_run deploy at 01:17:34, which replaced the gateway process; Modal-primary routing was restored later the same night and has been healthy since (full 6-household parity passed on both channels).
Timeline (UTC, from gateway Cloud Run logs, instance …1765a782)
| Time | Event |
|---|---|
| ≤01:00 | All dispatches healthy (5-minute monitor getting 200s in 3–4s) |
| ~01:02 | First Modal dispatch hangs; 503s at exactly 90s (MODAL_REQUEST_TIMEOUT_SECONDS) begin |
| 01:02–01:15 | modal_request_failed events accumulate on both channels (8 by 01:07, 32 total by 01:15). Interleaved with successes: e.g. 01:10:08 → 200 in 4.3s, 01:10:09 → 503 after 90s, same instance, same endpoint |
| 01:15:04 | First modal_executor_saturated (backend: none) — instant 503s without attempting Modal |
| 01:17:34 | Force-flag revision deploys, gunicorn restarts, traffic pinned to Cloud Run — recovery |
| ~01:55 | Force flag removed; Modal-primary routing verified healthy (x-policyengine-backend: modal) |
Root cause
External trigger: starting ~01:02, a subset of the gateway's Modal SDK dispatches to Modal's control plane hung indefinitely — silent black-hole, no error. Success/failure was per-dispatch: the same gateway instance served 3–4s 200s and 90s 503s in the same minute, on both channels. (Consistent with silently dead gRPC connections in one of the two gunicorn worker processes; the 5-minute monitor's keep-alive connection stayed pinned to a healthy path and never failed. Worker-side logs confirm hung dispatches never reached the workers.)
Gateway defect 1 — no timeout on the Modal call itself. _call_modal_worker_dispatch (cloud_run_gateway.py) never passes timeout_seconds to call_modal_worker_dispatch, whose docstring warns that without it Modal waits indefinitely. The 90s bound exists only in _run_modal_operation's future.result(timeout=...).
Gateway defect 2 — permanent executor-slot leak. After the 90s timeout, future.cancel() is a no-op on a running thread, and the semaphore permit is released only by the done-callback, which never fires for a permanently hung call. Each hang permanently consumed one of the 32 MODAL_EXECUTOR_MAX_WORKERS slots. The arithmetic matches exactly: 8 + 24 = 32 hung dispatches, then modal_executor_saturated at 01:15:04. The catch-all route accelerated this by dispatching junk paths (/, /healthz, scanner probes) to Modal, burning slots.
Gateway defect 3 — circuit breaker blind to the failure mode. Zero modal_circuit_open events were logged. Health probes and the canary run on the dedicated _MODAL_PROBE_EXECUTOR with their own connections, which stayed healthy — so recovery checks kept passing while the request path bled out, and the circuit never opened. A partial-connection black-hole structurally defeats the canary-corroboration requirement; the outage lasted ~15 minutes instead of the ~1 minute the sliding-window thresholds intend.
Proposed fixes (in priority order)
- Pass
timeout_seconds(~90s) intocall_modal_worker_dispatchfrom the gateway request path. The spawn+get(timeout)plumbing already exists inworker_dispatch.py. This converts a permanent slot leak into a 90s-bounded one and would have prevented the saturation phase entirely. - Let request-path failure evidence open the circuit when probes disagree. Sustained
modal_request_failedvolume over the sliding window should be able to open the channel circuit (or at least force fallback attempts) even when the canary passes, since probe connections can be healthy while request-path connections are not. - Stop dispatching unknown paths to Modal. Return 404 at the gateway for paths outside the known API surface instead of forwarding scanner noise through the dispatch executor.
- (Hardening) Recycle gunicorn workers periodically (
max_requests+ jitter) or add Modal client connection health-checking, so silently dead control-plane connections self-heal without a deploy.
Evidence pointers
- Gateway logs:
gcloud logging read 'resource.type="cloud_run_revision" AND resource.labels.service_name="household-api-production-gateway" AND timestamp>="2026-07-21T00:40:00Z" AND timestamp<="2026-07-21T01:25:00Z"' --project policyengine-household-api - Worker logs (both channels healthy throughout):
modal app logsforpolicyengine-household-api-uk2-88-18-us1-768-1/-us1-771-2, window 00:45–01:20 UTC - If pursuing the external trigger: Modal support ticket with window 2026-07-21 01:02–01:17 UTC, workspace apps above
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start in cloud_run_gateway.py at _call_modal_worker_dispatch and _run_modal_operation, then inspect the timeout plumbing and call_modal_worker_dispatch docstring in worker_dispatch.py. Use the supplied gateway and worker log queries to confirm the failure pattern. Done means the selected priority fixes address bounded dispatches, request-path failure handling, and unknown-path routing without regressing healthy Modal-primary traffic.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- python
- Domain
- api, backend, cloud
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Quiet
- Clarity
- Mostly clear
- Newbie friendliness
- 48/100