Skip to content

Archive rounds through the hosted job API (Modal 150s ceiling) - #236

Merged
vahid-ahmadi merged 2 commits into
mainfrom
vahid/nowcast-via-job
Nov 11, 2026
Merged

vahid-ahmadi merged 2 commits into
mainfrom
vahid/nowcast-via-job

Conversation

@vahid-ahmadi

@vahid-ahmadi vahid-ahmadi commented Nov 11, 2026 •

Copy link
Copy Markdown
Contributor

Cause

Archive a forecast round before release, run 42963724470 (11 Nov, main e84d8c19):

  • --due 3 passed: 2026-11-12 uk_gdp_cvm. The release calendar was not stale on this run.
  • --edge-gate passed: hosted data edge 2026Q2 >= 2026Q2; a new round is possible.
  • The round then failed in forecasts/nowcast.py --draws 6000, which calls forecast_uk directly on the hosted server. The step started at 05:08:26 and failed at 05:11:04:
  File "forecasts/nowcast.py", line 222, in call
    async with streamablehttp_client(MCP_URL, timeout=600) as (read, write, _):
  File ".../mcp/client/streamable_http.py", line 380, in _handle_post_request
    _raise_for_unfollowed_redirect(response)
httpx.HTTPStatusError: Redirect to https://policyengine--policyengine-macro-mcp-serve.modal.run/mcp not followed; use that URL as the endpoint if it is the intended server

Modal answers any web-endpoint request that runs past 150 s with HTTP 303. A 6000-draw forecast_uk takes about 5.5 minutes on a cold container, by the workflow's own measured comment, and the direct call now fails at the ceiling. The same direct call did complete in August (12–13 and 17–19 Aug, per test_nowcast_edge_gate.py), so either the server or Modal's handling of long requests has changed since then; either way the direct route is no longer usable.

Following the redirect is not a fix:

  • mcp 1.30 follows only 307/308 for a POST.
  • A 303 turns the POST into a GET, and Modal's result URL rejects that with 400 modal-http: bad redirect method. That was measured when the job API was built; see integration/src/policyengine_macro/jobs.py.

Fix

Client: forecasts/nowcast.py. It now does what score_reform callers already do:

  • start_job(tool="svar_forecast", arguments={horizons, draws}), then get_job_result polls with wait_seconds=30.
  • Each MCP call is a fresh session with a 120 s HTTP timeout.
  • A transport error on a poll is retried, up to 10 in a row. A tool error, meaning the job failed, is fatal.
  • It gives up after 17 minutes, naming the job id, which is inside the step's 20-minute timeout.

Server: jobs.JOB_TOOLS gains svar_forecast, the adapter behind forecast_uk. The start_job description now mentions it. core.svar_forecast enforces the same draws/horizons bounds as the tool (_bounded_int), so the job path does not widen what a caller can request. The deploy copies integration/ from the checked-out commit (add_local_dir), so a deploy from main ships the new allow-list.

  • The job worker (run_tool_job) already has a 30-minute budget and the same image.
  • Until the server is redeployed it refuses svar_forecast as "cannot be run as a job". nowcast.py turns that into a plain "redeploy first" message.

Workflow comment in nowcast-round.yml updated. No behaviour changes there.

This needs a deploy to work. Merging touches integration/**, so Deploy MCP server to Modal runs on merge. Its modal deploy step publishes the new allow-list even though its smoke-test step is red (see the companion PR for the smoke tests). The live job path cannot be exercised before merge without a manual deploy, so it is untested live. The client logic is unit-tested against a scripted server.

Tests

forecasts/tests/test_nowcast_job.py (13 tests). Ten drive run_as_job against a scripted server:

  • polls through running → done;
  • never calls forecast_uk directly;
  • retries a failed poll and gives up after too many;
  • treats a job error as fatal;
  • turns the "cannot be run as a job" refusal into a redeploy message;
  • stops at the deadline with the job id;
  • keeps the deadline inside the workflow step timeout;
  • pins svar_forecast to JOB_TOOLS and to what forecast_uk calls.

Three more run fetch_forecast's real call path with the MCP client replaced by fakes that hold live anyio task groups, as the real client does. The first commit raised HostedToolError inside those contexts, so an ExceptionGroup wrapped it and the error was retried as a transport failure: "Redeploy" never printed and a failed job was polled again. The second commit (f08fb7d5) judges the reply after the contexts close. Against the first commit, 2 of these 3 tests fail; on this branch all 3 pass. They are skipped where anyio is not installed (the plain-python CI gates).

Results:

  • On main: they error, because run_as_job does not exist.
  • On this branch: forecasts/tests passes in full (13/13 for the new file, run with anyio installed).
  • integration/tests/test_jobs.py, test_drift_check.py and test_tool_surface.py: 89 passed.

After merge

Wait for the deploy step to finish (not the smoke test), then run Actions → Archive a forecast round before release → Run workflow on main with the defaults. The round does not depend on #195: data/latest already announces uk_gdp_cvm for 12 Nov.

There is no DATA_PR_TOKEN secret yet (#223). The round is pushed to forecasts/nowcast-round before the PR step, so if that step fails, open the PR from the compare link it prints.

🤖 Generated with Claude Code

Modal answers any web request past 150s with HTTP 303, and a 6000-draw
forecast_uk takes ~5.5 minutes on a cold container, so the archive run
42963724470 (due for the 12 Nov GDP release) failed with "Redirect to
.../mcp not followed" after ~160s. Following the 303 cannot work: it turns
the POST into a GET, which Modal's result URL rejects.

nowcast.py now starts svar_forecast (forecast_uk's adapter) with start_job
and collects it with 30s get_job_result polls: transport errors on a poll
are retried, a job error is fatal, and it gives up with the job id after
17 minutes, inside the step's 20. svar_forecast joins jobs.JOB_TOOLS, so
the server needs the redeploy that merging this triggers; until then the
script says so instead of failing obscurely.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@vercel

vercel Bot commented Nov 11, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
macromod Ready Ready Preview Nov 11, 2026 10:22am UTC

Request Review

streamablehttp_client and ClientSession both run anyio task groups, which
wrap anything raised inside them in an ExceptionGroup. A HostedToolError
raised in there reached run_as_job as a transport error, so a failed job
was retried and the "Redeploy" message could never print. Judge the reply
after both contexts close, and test it through real task groups.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@vahid-ahmadi
vahid-ahmadi marked this pull request as ready for review November 11, 2026 10:24
@vahid-ahmadi
vahid-ahmadi merged commit b84975a into main Nov 11, 2026
7 checks passed

This branch was successfully deployed

1 active deployment
Preview — f08fb7d5 Deployed Nov 11, 2026 by vercel[bot]
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.

1 participant