Repository navigation
Retry the full deploy check once after a lost connection - #245
Merged
Merged
Conversation
Deploy run 39472402849 failed because the connection to the staged deployment closed without a response after about 272 s, while Modal finished the calculation and returned 200. These tests describe the retry that --check full needs: - A loopback HTTP server closes the first connection without a response and answers later requests. The check must wait for the backend with a metadata request, then retry the calculation once. - A retried response must still show that both scopes were built and the national simulation was released. The test accepts the cache state of a warm container and rejects states that show neither. - A response that fails validation, or an HTTP error with a body, must fail at once with a single request. - The wait for the backend is limited by a deadline. - RemoteDisconnected, ConnectionResetError, URLError and TimeoutError are reported as connection failures; HTTPError is not. On the code before this change: 12 tests fail and 11 pass. Seven fail with http.client.RemoteDisconnected (no retry), four with a missing ConnectionFailedError, and one with a missing BACKEND_FREE_DEADLINE_SECONDS. Three new tests pass already, because the current code never retries. Refs #244 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
--check full sent one request and treated a connection closed before any response as a failed deploy. In run 39472402849 that happened about 272 s in, while Modal finished the calculation and returned 200. RemoteDisconnected is a ConnectionResetError, not a URLError, so the script did not catch it at all. - _request_json reports RemoteDisconnected, ConnectionResetError, URLError and TimeoutError as ConnectionFailedError, a RuntimeError subclass. HTTPError and invalid responses stay plain RuntimeErrors. The metadata and state checks already retry RuntimeErrors, so they now also retry a connection closed without a response. - After a ConnectionFailedError, --check full waits until the backend answers a valid metadata request, then sends the calculation once more. The backend serves one request at a time and keeps running the abandoned calculation, so the metadata answer shows it has finished. Lost probe connections are retried until 860 s after the first request: Modal's 800 s function timeout plus 60 s for container start. An HTTP error or invalid metadata fails at once. - The retried response must pass the same validation. The cache holds one simulation, so a retry in the same warm container reports both scopes as built, with evictions ["CA", "national"], and passes. - A response that fails validation, or an HTTP error, still fails the deploy at once with a single request. The step's longest possible run grows from 790 s to about 1650 s (860 s until the backend must be free, then a 790 s retry). The other steps of the job took about 3 minutes in today's runs, so the job stays inside its 45-minute limit. All 23 tests in frontend/tests/test_deployment_smoke.py pass; the tests are unchanged from the previous commit. Fixes #244 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
The full check treats every URLError as a lost connection. A URLError for a failed TLS certificate check, a failed DNS lookup or a missing host therefore makes the check warn that it is waiting for a calculation that was never started, send a metadata request every 10 seconds for up to 860 seconds, and only then fail. Before the retry existed, it failed at once. These tests require that only a refused, closed, reset, aborted or timed-out connection counts as a lost connection, whether urllib raises it directly or wraps it in a URLError. A raw ConnectionAbortedError currently escapes without being classified. Result on this commit: 5 failed, 23 passed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A URLError now counts as a lost connection only when its reason is a ConnectionError or a TimeoutError. A failed TLS certificate check, a failed DNS lookup or a URL problem fails the full check at once, as it did before the retry existed, instead of waiting up to 860 seconds for a calculation that was never started. The same rule applies to errors urllib raises directly, so a raw ConnectionAbortedError is now reported as a lost connection instead of escaping unclassified. The metadata and state checks are unaffected: they already retry every RuntimeError up to six times. HOSTED_RUNTIME.md now lists the refused connection, says "complete response", and names DNS and TLS-certificate failures among the errors that are never retried. Result: 28 passed (5 failed, 23 passed on the previous commit); the test file is unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This branch was successfully deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #244
Cause
The deploy step "Run staged national and state calculations" runs
frontend/scripts/deployment_smoke.py --check full. That check sends one request forspm_unit_spm_threshold(national) andca_income_tax(California) to the staged Vercel deployment, and it never retried (attempts=1).In deploy run 39472402849 the connection closed without a response 272 s after the request was sent (
http.client.RemoteDisconnected). Meanwhile, Modal's log shows the calculation returning 200 after 303.9 s, and Vercel's log also records 200. Every configured limit is higher: 790 s for the client, 800 s for the Vercel route, 780 s for the proxy and 800 s for the Modal function.RemoteDisconnectedis aConnectionResetError, not aURLError, so the script did not catch it at all. That also means the six-attempt loops of the metadata and state checks never retried it.Design
1. Cache state seen by a retry
frontend/scripts/microcosm_variable_core.pykeeps at most one simulation:_simulation_forclears_SIM_CACHEbefore building a new scope.simulation_cache_hits[scope]records whether that scope's simulation was already cached when the request first used it.simulation_cache_evictionslists the scopes released to build another one.So the abandoned request leaves the California simulation in the cache. A retry in the same warm container then:
nationalmiss, evictions["CA"]);CAmiss, evictions["CA", "national"]).The existing validator already accepts this, because both scopes are misses and
"national"is in the evictions. In a new container, the evictions are["national"], which also passes.The validator is therefore unchanged, and each response, first or retried, must prove the scope switch on its own. A retry that reports the national simulation as reused, or never released, is rejected; both cases are tested.
The only path to a cached national simulation is an abandoned request that failed after building it. Nothing else uses the staged commit-specific Modal app, and the metadata requests do not touch the cache. If that happens, the retry fails, which is correct.
2. Queueing: wait for a metadata answer, not a fixed time
backend/modal_app.pyruns one container that serves one input at a time (max_containers=1,@modal.concurrent(max_inputs=1)). The route's proxy does not pass the client's abort signal to Modal, so the abandoned calculation keeps running.After a lost connection, the check sends
metadata=1requests. These go through the same route to the same container, so one is answered only after the abandoned calculation has finished. The answer must also passvalidate_metadata. The full calculation is then sent once more.Why this instead of a fixed wait:
scaledown_window=60lets the container shut down. The retry would then also pay for a cold start, which makes its idle connection longer; that is the condition that failed.A metadata request whose connection is lost is retried, with a 10 s pause, until 860 s after the first full request: Modal's 800 s function timeout plus 60 s for container start. Each probe's socket timeout is limited to the time left. Any HTTP error or invalid metadata fails at once.
Time bound: the step's longest possible run grows from 790 s to about 1,650 s (860 s by which the backend must be free, plus one 790 s retry), so at most about 14 minutes more. In today's four runs, the steps before this one took about 2.5 to 3 minutes and the steps after it about 20 seconds. The job therefore stays at or below about 31 minutes, inside
timeout-minutes: 45. Had this code run in 39472402849, the step would have taken about 272 s + 32 s + one warm calculation (roughly 190 to 300 s).3. Which errors are retried
_request_jsonnow raisesConnectionFailedError, aRuntimeErrorsubclass, only for aConnectionError(refused, closed, reset or aborted;RemoteDisconnectedis aConnectionResetError) or aTimeoutError, whether raised directly or wrapped by urllib in aURLError. AURLErrorfor a DNS, TLS-certificate or URL problem stays a plainRuntimeError(changed in review; see "Review fixes").HTTPError, which is aURLError, is handled first and stays a plainRuntimeError.For
--check full, onlyConnectionFailedErrorleads to the wait and the single retry. An HTTP 4xx or 5xx response with a body, a non-JSON response, or a response that fails validation still fails the deploy at once, after one request.A slow backend is not hidden: the proxy answers HTTP 504 after 780 s, before the client's 790 s timeout, and that 504 is not retried. One limit remains: the outcome of the abandoned calculation itself is not observed. Only the retried response, validated in full, is accepted.
Side effect: the metadata and state checks already retried every
RuntimeErrorup to six times. They now also retry a connection closed without a response, which previously escaped their retry loops. A test covers this.frontend/HOSTED_RUNTIME.mddescribes the retry in the deployment steps.Commits and test results
frontend/tests/test_deployment_smoke.pya326464363fc58Failure reasons on
a326464:http.client.RemoteDisconnected: Remote end closed connection without response, the same exception as the failed deploy, because there was no retry: retry once, keep waiting through lost probe connections, the two rejected cache states, the retried connection closing again, the probe reporting another build, and the metadata check.AttributeError: module 'scripts.deployment_smoke' has no attribute 'ConnectionFailedError': the classification ofRemoteDisconnected,ConnectionResetError,URLErrorandTimeoutError.AttributeError: ... no attribute 'BACKEND_FREE_DEADLINE_SECONDS': the limit on the wait.The full-check tests use a loopback HTTP server on 127.0.0.1. It closes the first connection without a response and answers later requests from a fixed sequence of replies.
Checks run:
pytest frontend/tests/test_deployment_smoke.py(23 passed), andruff checkandruff format --checkon both changed Python files.Deployment wiring
.github/workflows/deploy.ymland.github/scripts/deploy-dashboard.share unchanged, so this does not overlap with #234. The script reads no environment variables. The metadata requests reuse the same headers as the calculation, includingx-vercel-protection-bypass. That header comes from--vercel-bypass-secret "$VERCEL_AUTOMATION_BYPASS_SECRET", which is the GitHub Actions secretVERCEL_AUTOMATION_BYPASS_SECRET, set in the step'senv:. The step "Verify staged frontend metadata" used the same secret successfully in run 39472402849.Review fixes
The review found one low-severity bug. Every
URLErrorcounted as a lost connection, including a failed TLS certificate check, a failed DNS lookup and a missing host. On the first full request, such an error made the check warn that it was waiting for a calculation that had never started. It then sent a metadata request every 10 s for up to 860 s, and only then failed. Before this PR, it failed at once.frontend/tests/test_deployment_smoke.pyfd2d1ed7845dd0Failure reasons on
fd2d1ed:URLErrorfor a TLS certificate failure, a DNS failure and a missing host was raised asConnectionFailedErrorinstead of a plainRuntimeError.ConnectionAbortedErrorescaped_request_jsonwithout being classified.['full', 'metadata'] == ['full']failed).The fix counts a failure as a lost connection only when it is a
ConnectionErroror aTimeoutError, raised directly or as aURLError's reason. The metadata and state checks are unaffected, because they retry everyRuntimeError. A temporary DNS failure on the first full request now fails the deploy at once, as it did before this PR.frontend/HOSTED_RUNTIME.mdnow mentions a refused connection, says "complete response", and names DNS and TLS-certificate failures among the errors that are never retried.Checks run:
pytest frontend/tests/test_deployment_smoke.py(28 passed), andruff checkandruff format --checkon both changed Python files.🤖 Generated with Claude Code