Skip to content

Say in a failed economy answer how long its failure is kept - #4172

Merged
anth-volk merged 4 commits into
masterfrom
feat/economy-retry-after-seconds
Nov 4, 2026
Merged

anth-volk merged 4 commits into
masterfrom
feat/economy-retry-after-seconds

Conversation

@anth-volk

@anth-volk anth-volk commented Nov 4, 2026 •

Copy link
Copy Markdown
Collaborator

Fixes #4171

What this changes

A failed economy calculation's 502 body from GET /<country_id>/economy/<policy_id>/over/<baseline_policy_id> now has retry_after_seconds:

{"status": "error", "message": "...", "result": null, "retry_after_seconds": 120}

It is the whole number of seconds, rounded up, until lookups stop finding the stored failure. Until then every request gets the same failure; the first request after it submits a new job. Clients can read this number instead of parsing the message ("A new run for this state can be requested 2 minutes after this failure.", from #4064), which PolicyEngine/policyengine-app-v2#1439 does today.

  • Where the number comes from: the runtime cache's own expiry. A lookup finds a record only through its scope index, so the value is the earlier of the record's expiry and the index's (Redis PTTL on both), and 0 once the index has expired. It is read after a fresh failure is written and again on every replay of a stored one, so it is exact for a two-minute US state or local-area breakdown failure, for a failure kept the usual 30 days, and for a US state failure stored before Let a failed US state economy run be retried after two minutes #4064.
  • When it is null:
    • no failure is stored: a local-area breakdown request the simulation API refused, or an error write the cache didn't store;
    • the next request would answer from a newer job: a late poll of a job a newer one replaced. A fresh failure is checked with the next request's cache lookup, which makes no request to the simulation API;
    • the cache can't be read.
  • It is the longest wait. A deploy that changes the versions or worker app a request resolves to makes the record unfindable sooner, and the next request then starts a new run.
  • Unchanged: the message text, the status code, and every other body.

Changes by module

  • runtime_cache/reform_impacts.py: ReformImpactCache.seconds_left(impact), the earlier of the record's and scope index's PTTL, rounded up; None without a worker handle, for a missing or never-expiring record, or on RedisError/OSError.
  • runtime_cache/core.py, runtime_cache/fake.py: pttl on the cache backend protocol and the in-memory fake, and the PTTL reply constants (-2 missing, -1 no expiry).
  • services/reform_impacts_service.py: get_reform_impact_seconds_left(impact).
  • services/economy_impact_types.py: EconomicImpactResult.retry_after_seconds, also in to_dict().
  • services/economy_stored_impacts.py: _set_reform_impact_error returns the stored failure; _failure_the_next_request_answers_from checks a fresh failure against the next request's cache lookup (_get_most_recent_impact); _stored_failure_result reads the seconds.
  • services/economy_impact_execution.py: the three answers with a stored failure (fresh and kept 30 days, fresh and kept two minutes, replayed) go through those helpers.
  • routes/economy_routes.py: the 502 body carries the field.
  • docs/canonical-spm.md: the field and when it is null.

No inline suppressions are added.

Known timing limits

  • Two requests that poll the same computing job write its failure one after the other, and the second write restarts the record's lifetime. The first answer's value is then short by the second poll's delay, a few seconds; the next answer gives the right remainder.
  • A later write in the same scope can extend the scope index's expiry after a value was given. This only matters for the 30-day lifetime, by up to the jitter.

Tests

Written first and seen failing, then passing, in four rounds:

  • Cache: the remaining lifetime is rounded up; a scope index that expires before the record shortens it, and an expired index gives 0; a missing record, a record without a worker handle, Redis's no-expiry reply and an unreachable cache give None.
  • Service and failure paths: each path answers with the seconds its stored failure is kept (120 for a US state job, the 30-day lifetime elsewhere, the remainder on replay); an error write the cache refused, and a next lookup that finds nothing or another job, give None, on the two-minute and 30-day paths; a failing poll asks the simulation API for no worker app.
  • Route, with a real in-memory cache: a US state failure answers 120, then 90 after 30.5 seconds, and after those 90 seconds the next request submits a new run; a 30-day failure answers its full lifetime on every replay; a refused breakdown and a mocked failure answer null.
  • Existing tests updated: six earlier tests that compared a failure exactly against a real in-memory cache, the three to_dict tests, the late poll of a replaced breakdown job, and the two exact 502 bodies now expect the field; the shared ReformImpactsService stand-in returns None for it.

Only the added and changed tests were run locally, by node ID, with ruff check, ruff format --check, mypy and scripts/run_quality_guards.py. The full suite runs in CI. Three independent review rounds were run on the branch; their findings are fixed in the second to fourth commits.

Release

Additive: clients that don't read the field are unaffected. PolicyEngine/policyengine-app-v2#1439 reads it, and should be released only after this is deployed.

🤖 Generated with Claude Code

anth-volk and others added 3 commits November 4, 2026 12:52
A failed economy calculation's 502 body carried when a new run could be
requested only as a sentence in its message, so a client had to parse
the text to know when to offer Retry. The body now has
`retry_after_seconds`: the whole seconds, rounded up, until the stored
failure's record expires and the next request submits a new job.

It is read from the record's own expiry in the cache (PTTL), after the
failure is written and on every replay, so it is exact for a two-minute
US state or local-area breakdown failure, for a failure kept the usual
30 days, and for one stored before failures were kept for two minutes.
It is null when no failure is stored (a refused breakdown request) or
when the record or the cache cannot be read. The message is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…x's expiry

- When the cache refused the error write, retry_after_seconds was read
  from the still-computing record, about 30 days, though no failure was
  stored. The error write now returns the stored failure, and only a
  stored failure gets a value; otherwise it is null.
- A lookup finds a record only through its scope index, which a later
  job in the same scope can give a shorter lifetime than the record's.
  The value is now the earlier of the two expiries, and 0 once the index
  has expired.
- The value is documented as the longest wait: a deploy that changes the
  runtime cache version stops the record being found sooner.
- The cache read catches RedisError and OSError instead of Exception, so
  it needs no inline suppression, and the PTTL reply constants live in
  runtime_cache/core.py.
- The three to_dict tests expect the new key; new tests cover a refused
  error write, an index that expires first, Redis's no-expiry reply, and
  a new run once the wait has passed. Code comments name the Public API.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A poll of a job found through another record's lookup (by the
options-hash prefix, or under a worker app the request no longer
resolves to), or a late poll of a job a newer one has replaced, writes
its failure onto a record the next request does not select: that
request starts a new run, or answers from the newer job. The answer
still reported that record's lifetime, up to 30 days, so a client hid
Retry while a new run could start at once.

A fresh failure now gets retry_after_seconds only when the next
request's own lookup, run on a copy of the request's options, answers
from that same stored failure; otherwise it is null. A replayed failure
is the one this request's lookup chose, so it keeps its value. The
route, the result type and docs/canonical-spm.md describe the field and
when it is null.

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

codecov Bot commented Nov 4, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 89.83051% with 6 lines in your changes missing coverage. Please review.
✅ Project coverage is 88.30%. Comparing base (1dc77a1) to head (8624a91).
⚠️ Report is 6 commits behind head on master.

Files with missing lines Patch % Lines
policyengine_api/runtime_cache/fake.py 78.57% 2 Missing and 1 partial ⚠️
policyengine_api/runtime_cache/reform_impacts.py 88.23% 1 Missing and 1 partial ⚠️
policyengine_api/runtime_cache/core.py 75.00% 1 Missing ⚠️
Additional details and impacted files
@@             Coverage Diff             @@
##           master    #4172       +/-   ##
===========================================
+ Coverage   53.93%   88.30%   +34.37%     
===========================================
  Files         281      293       +12     
  Lines       16261    17506     +1245     
  Branches     2629     2875      +246     
===========================================
+ Hits         8770    15459     +6689     
+ Misses       6760     1335     -5425     
+ Partials      731      712       -19     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

The check that the next request answers from a fresh failure ran the
full reusable-impact lookup, whose refresh asks the simulation API for
the worker app. That added a request to the simulation API on every
failing poll, where a poll had made none, and when that request failed
the refresh was skipped and the failure kept its full lifetime anyway.

The check is now the cache lookup alone (_get_most_recent_impact): it
still gives no seconds for a late poll of a job a newer one replaced,
and makes no request to the simulation API. A deploy that changes the
worker app a request resolves to falls under the documented longest
wait, as a version change does.

The route, the result type, the two-minute failure's docstring and
docs/canonical-spm.md now name the newer-job case; tests check that a
failing poll asks the simulation API for no worker app, and give no
seconds on the 30-day path as well as the two-minute one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@anth-volk
anth-volk marked this pull request as ready for review November 4, 2026 10:13
@anth-volk
anth-volk merged commit dfcbf2e into master Nov 4, 2026
22 checks passed
@anth-volk
anth-volk deleted the feat/economy-retry-after-seconds branch November 4, 2026 10:14

This branch was previously deployed

1 inactive deployment
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.

Say in a failed economy answer how long its failure is kept

1 participant