From d455cfb75f30c4abb2f5687b6e5a10ee8bbffd7a Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Wed, 28 Oct 2026 01:41:03 +0400 Subject: [PATCH 1/3] Add failing tests for unsupported economy years When the simulation service refuses a year its model cannot project the dataset to, it answers the job poll with 400 and the stable code DATASET_YEAR_UNAVAILABLE. The Public API does not recognise that code, so the poll raises an unhandled HTTP error. Before that change reaches the simulation service, the same request fails as a stored 502. These tests expect the code to be preserved as a typed error: the economy route returns 400 with the code and its readable message, and later polls replay the stored typed failure without polling the worker again. They also expect the UK years in the country metadata to run to the last year the installed policyengine-uk projects its dataset to (2032 today), not 2030. Co-Authored-By: Claude Opus 5.5 --- .../routes/test_spm_year_worker_polling.py | 78 +++++++++++++++++++ tests/unit/test_country.py | 58 ++++++++++++++ tests/unit/test_spm_settings.py | 18 +++++ 3 files changed, 154 insertions(+) diff --git a/tests/unit/routes/test_spm_year_worker_polling.py b/tests/unit/routes/test_spm_year_worker_polling.py index 17f3bd203..9c8a3fc9e 100644 --- a/tests/unit/routes/test_spm_year_worker_polling.py +++ b/tests/unit/routes/test_spm_year_worker_polling.py @@ -34,6 +34,13 @@ "code": "SPM_YEAR_UNAVAILABLE", "message": "Forecast has no entry for 2036", } +DATASET_YEAR_ERROR = { + "code": "DATASET_YEAR_UNAVAILABLE", + "message": ( + "US results are available for 2024 to 2035. 2036 is outside the years " + "the current US model and data cover." + ), +} def segmented_result(year, invalid_side=None): @@ -371,6 +378,77 @@ def test_typed_worker_error_is_terminal_and_replays_after_service_recreation( assert worker.received.count(("GET", job_path)) == 1 +@pytest.mark.parametrize("budget_window", [False, True], ids=["annual", "window"]) +def test_a_dataset_year_error_is_a_terminal_400_replayed_from_storage( + monkeypatch, budget_window +): + """A year the worker's model cannot project is a caller error, not a 502. + + The simulation service answers the poll with 400 and the stable code + DATASET_YEAR_UNAVAILABLE. The route returns that code and its readable + message, and later polls replay the stored typed failure without asking + the worker again. + """ + _patch_spm_release(monkeypatch) + job_id = "dataset-year-job" + job_path = f"/budget-window-jobs/{job_id}" if budget_window else f"/jobs/{job_id}" + worker = _FailingJobWorker( + job_path=job_path, + failure=400, + typed_error=DATASET_YEAR_ERROR, + worker_result=None, + ) + + gateway = _worker_gateway() + backend = InMemoryCacheBackend() + namespace = CacheNamespace("test", "dataset-year-worker-polling") + stack = _install_economy_stack(monkeypatch, gateway, backend, namespace) + app = _economy_app() + + with httpx.Client(transport=httpx.MockTransport(worker)) as gateway.client: + setup = _setup_options(stack.service, budget_window=budget_window) + if budget_window: + cache_key = stack.service._build_budget_window_cache_key(setup) + stack.window_cache.store_submitted( + cache_key, + job_id, + setup.observability_id, + ) + else: + _store_annual_impact( + stack, + setup, + status="computing", + reform_impact_json={}, + execution_id=job_id, + ) + url, query = _economy_request(budget_window=budget_window) + + worker.received.clear() + for _ in range(2): + response = _get_economy(app, url, query) + assert response.status_code == 400, response.json + assert response.json == { + "status": "error", + "result": None, + "message": DATASET_YEAR_ERROR["message"], + "errors": [DATASET_YEAR_ERROR], + } + if budget_window: + _assert_window_failure_recorded( + stack.window_cache.get_state(cache_key), DATASET_YEAR_ERROR, setup + ) + else: + _assert_annual_failure_recorded( + stack.annual_cache.get_by_execution_id(job_id), + DATASET_YEAR_ERROR, + setup, + ) + stack = _install_economy_stack(monkeypatch, gateway, backend, namespace) + + assert worker.received.count(("GET", job_path)) == 1 + + @pytest.mark.parametrize("budget_window", [False, True], ids=["annual", "window"]) def test_an_uncertifiable_stored_result_becomes_terminal_instead_of_repeating( monkeypatch, budget_window diff --git a/tests/unit/test_country.py b/tests/unit/test_country.py index 48af0f3eb..5f74397db 100644 --- a/tests/unit/test_country.py +++ b/tests/unit/test_country.py @@ -197,6 +197,46 @@ def get_population(self, entity_plural): ) +UK_DATASET_DATA_YEAR = 2024 + + +def uk_model_projected_years(data_year): + """Return the years policyengine-uk's own projection gives a dataset. + + Projects a three-household dataset of ``data_year`` with the installed + model, as the simulation service's model projects the UK dataset. + """ + from policyengine_uk.data.dataset_schema import UKSingleYearDataset + from policyengine_uk.data.economic_assumptions import extend_single_year_dataset + from policyengine_uk.system import system + + household_ids = np.arange(1, 4) + dataset = UKSingleYearDataset( + person=pd.DataFrame( + { + "person_id": household_ids, + "person_benunit_id": household_ids, + "person_household_id": household_ids, + "age": [45, 30, 70], + } + ), + benunit=pd.DataFrame({"benunit_id": household_ids}), + household=pd.DataFrame( + { + "household_id": household_ids, + "household_weight": [2.0, 3.0, 5.0], + "region": "LONDON", + "tenure_type": "OWNED_OUTRIGHT", + "rent": 0.0, + "council_tax": 0.0, + } + ), + fiscal_year=data_year, + ) + projected = extend_single_year_dataset(dataset, system.parameters) + return sorted(int(year) for year in projected.years) + + class TestUKCountryMetadata: """Tests for UK country metadata, specifically local authority loading.""" @@ -208,6 +248,24 @@ def uk_country(self): def uk_regions(self, uk_country): return uk_country.metadata["economy_options"]["region"] + def test__uk_years_run_to_the_models_last_projected_year(self, uk_country): + """The UK years end where the installed policyengine-uk projects its data. + + The simulation service refuses a later year (DATASET_YEAR_UNAVAILABLE), + and the app offers no year after the last one listed here. + """ + years = [ + period["name"] + for period in uk_country.metadata["economy_options"]["time_period"] + ] + projected_years = uk_model_projected_years(UK_DATASET_DATA_YEAR) + + assert years == projected_years + assert [ + period["label"] + for period in uk_country.metadata["economy_options"]["time_period"] + ] == [str(year) for year in projected_years] + def test__uk_metadata_contains_local_authorities(self, uk_regions): """Verify that local authorities are included in UK region options.""" local_authority_regions = [ diff --git a/tests/unit/test_spm_settings.py b/tests/unit/test_spm_settings.py index f754de006..e17a78629 100644 --- a/tests/unit/test_spm_settings.py +++ b/tests/unit/test_spm_settings.py @@ -319,6 +319,24 @@ def test_typed_worker_year_error_is_preserved(status): assert caught.value.to_dict() == detail +@pytest.mark.parametrize("status", [400, 422]) +def test_typed_worker_dataset_year_error_is_preserved(status): + import httpx + + from policyengine_api.worker_spm import raise_worker_spm_error + + detail = { + "code": "DATASET_YEAR_UNAVAILABLE", + "message": ( + "UK results are available for 2024 to 2032. 2033 is outside the years " + "the current UK model and data cover." + ), + } + with pytest.raises(spm.SPMValidationError) as caught: + raise_worker_spm_error(httpx.Response(status, json={"errors": [detail]})) + assert caught.value.to_dict() == detail + + def test_variadic_constructor_alone_does_not_claim_support(): class Legacy: def __init__(self, **kwargs): From 5d7eb179decf7997c190e0f6cf148e3c13be9ece Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Wed, 28 Oct 2026 01:48:02 +0400 Subject: [PATCH 2/3] Return a 400 for economy years the model cannot project its dataset to The simulation service answers a job for a year its model cannot project the dataset to with 400 and the stable code DATASET_YEAR_UNAVAILABLE. raise_worker_spm_error now passes that code on with the SPM input codes, from one named set (WORKER_INPUT_ERROR_CODES), so the economy routes return 400 with the code and the service's readable message, such as "UK results are available for 2024 to 2032". The failure is stored as a typed error with its code, like the SPM input errors, and every later poll replays it as the same 400 without asking the worker again. The stored record is keyed by the resolved worker and bundle, so a model that projects further gets a fresh record. Unknown codes keep their existing handling. The UK years in the country metadata now run from 2024 to the last year the installed policyengine-uk projects its dataset to: PROJECTION_END_YEAR, shortened when the capital gains projection in its parameters ends earlier. That is 2032 today, where the fixed list stopped at 2030. Co-Authored-By: Claude Opus 5.5 --- changelog.d/unsupported-economy-year.fixed.md | 1 + policyengine_api/country.py | 10 ++--- policyengine_api/economy_years.py | 35 ++++++++++++++++++ policyengine_api/spm.py | 7 +++- policyengine_api/worker_spm.py | 37 +++++++++++++------ tests/unit/test_spm_settings.py | 12 ++++++ 6 files changed, 83 insertions(+), 19 deletions(-) create mode 100644 changelog.d/unsupported-economy-year.fixed.md create mode 100644 policyengine_api/economy_years.py diff --git a/changelog.d/unsupported-economy-year.fixed.md b/changelog.d/unsupported-economy-year.fixed.md new file mode 100644 index 000000000..92c21296a --- /dev/null +++ b/changelog.d/unsupported-economy-year.fixed.md @@ -0,0 +1 @@ +A society-wide (economy) calculation for a year the simulation service's model cannot project its dataset to, such as a UK year after 2032, now returns 400 with the code `DATASET_YEAR_UNAVAILABLE` and a readable message such as "UK results are available for 2024 to 2032", instead of a 502. The failure is stored and replayed as a typed error, like the SPM input errors. The UK years in the country metadata now run to the last year the installed policyengine-uk projects its dataset to (2032), instead of stopping at 2030. diff --git a/policyengine_api/country.py b/policyengine_api/country.py index 8c04362b4..0d2f46b9b 100644 --- a/policyengine_api/country.py +++ b/policyengine_api/country.py @@ -34,6 +34,7 @@ from policyengine_api.data.congressional_districts import ( build_congressional_district_metadata, ) +from policyengine_api.economy_years import uk_economy_years from policyengine_api.services.household_calculation_service import CalculationResult from policyengine_api.spm import ( SPM_INPUT_ERROR_CODES, @@ -231,13 +232,8 @@ def build_microsimulation_options(self) -> dict: for i in range(len(local_authority_names)) ) time_period = [ - {"name": 2024, "label": "2024"}, - {"name": 2025, "label": "2025"}, - {"name": 2026, "label": "2026"}, - {"name": 2027, "label": "2027"}, - {"name": 2028, "label": "2028"}, - {"name": 2029, "label": "2029"}, - {"name": 2030, "label": "2030"}, + {"name": year, "label": str(year)} + for year in uk_economy_years(self.tax_benefit_system.parameters) ] datasets = [get_bundle_default_dataset_option("uk")] options["region"] = region diff --git a/policyengine_api/economy_years.py b/policyengine_api/economy_years.py new file mode 100644 index 000000000..9838224c9 --- /dev/null +++ b/policyengine_api/economy_years.py @@ -0,0 +1,35 @@ +"""The UK years offered for society-wide (economy) calculations. + +The simulation service runs a UK year only from the data year of the UK +dataset to the last year the UK model projects that dataset to, and answers +any other year with ``DATASET_YEAR_UNAVAILABLE``. That last year is +policyengine-uk's ``PROJECTION_END_YEAR``, shortened when the capital gains +projection in the model's parameters ends earlier: the default end year of +policyengine-uk's own ``extend_single_year_dataset``. The Public API asks the +simulation service for the policyengine-uk version it has installed, so +reading the end year from the installed model keeps this list in step with +the model the simulation service runs. +""" + +from typing import TYPE_CHECKING + +from policyengine_uk.data.economic_assumptions import ( + CGT_INDEX_PATH, + PROJECTION_END_YEAR, +) + +if TYPE_CHECKING: + from policyengine_core.parameters import ParameterNode + +# The year of the data in the UK dataset (microcosm_uk_2024_25). The +# simulation service reads it from the dataset file and refuses earlier years. +UK_DATASET_DATA_YEAR = 2024 + + +def uk_economy_years(parameters: "ParameterNode") -> list[int]: + """Return each UK year from the dataset's data year to the last projected one.""" + projection = parameters.get_child(CGT_INDEX_PATH).metadata.get("projection", {}) + last_year = min( + PROJECTION_END_YEAR, int(projection.get("end_year", PROJECTION_END_YEAR)) + ) + return list(range(UK_DATASET_DATA_YEAR, max(UK_DATASET_DATA_YEAR, last_year) + 1)) diff --git a/policyengine_api/spm.py b/policyengine_api/spm.py index 65ef07e5f..47009abbe 100644 --- a/policyengine_api/spm.py +++ b/policyengine_api/spm.py @@ -354,7 +354,12 @@ def build_spm_comparison_provenance( class SPMValidationError(ValueError): - """An SPM request/configuration error safe for the API's 4xx envelope.""" + """An SPM request/configuration error safe for the API's 4xx envelope. + + It also carries the simulation service's other typed input errors, such + as ``DATASET_YEAR_UNAVAILABLE`` (see ``worker_spm.WORKER_INPUT_ERROR_CODES``), + so they are stored and replayed with their code in the same way. + """ def __init__(self, code: str, message: str) -> None: """Record a stable machine-readable error code and the client-facing message.""" diff --git a/policyengine_api/worker_spm.py b/policyengine_api/worker_spm.py index 742656be9..3b6b64ed8 100644 --- a/policyengine_api/worker_spm.py +++ b/policyengine_api/worker_spm.py @@ -198,8 +198,32 @@ def _check_annual_receipts( raise ValueError(msg) +# The typed input errors a worker reports that the Public API passes on to its +# caller as a 400 with the same code and message. Besides the SPM codes, +# DATASET_YEAR_UNAVAILABLE is the simulation service's answer for a year its +# model cannot project the dataset to, such as a UK year after 2032. Any other +# code keeps the existing handling of the worker's response. +WORKER_INPUT_ERROR_CODES = frozenset( + { + "SPM_GEOGRAPHY_REQUIRED", + "SPM_GEOGRAPHY_UNAVAILABLE", + "SPM_COMPOSITION_REQUIRED", + "SPM_YEAR_UNAVAILABLE", + "SPM_SETTINGS_INVALID", + "SPM_SETTINGS_UNSUPPORTED", + "SPM_CONFIGURATION_UNAVAILABLE", + "DATASET_YEAR_UNAVAILABLE", + } +) + + def raise_worker_spm_error(response: httpx.Response) -> None: - """Preserve public typed input errors across asynchronous HTTP polling.""" + """Preserve public typed input errors across asynchronous HTTP polling. + + Raise ``SPMValidationError`` with the worker's code and message for a 400 + or 422 whose ``errors`` list a code in ``WORKER_INPUT_ERROR_CODES``; return + for anything else. Callers store and replay that error as a typed failure. + """ if response.status_code not in (400, 422): return try: @@ -209,16 +233,7 @@ def raise_worker_spm_error(response: httpx.Response) -> None: for error in errors: if ( isinstance(error, dict) - and error.get("code") - in { - "SPM_GEOGRAPHY_REQUIRED", - "SPM_GEOGRAPHY_UNAVAILABLE", - "SPM_COMPOSITION_REQUIRED", - "SPM_YEAR_UNAVAILABLE", - "SPM_SETTINGS_INVALID", - "SPM_SETTINGS_UNSUPPORTED", - "SPM_CONFIGURATION_UNAVAILABLE", - } + and error.get("code") in WORKER_INPUT_ERROR_CODES and isinstance(error.get("message"), str) ): raise SPMValidationError(error["code"], error["message"]) diff --git a/tests/unit/test_spm_settings.py b/tests/unit/test_spm_settings.py index e17a78629..6ca8cf06e 100644 --- a/tests/unit/test_spm_settings.py +++ b/tests/unit/test_spm_settings.py @@ -337,6 +337,18 @@ def test_typed_worker_dataset_year_error_is_preserved(status): assert caught.value.to_dict() == detail +def test_an_unknown_worker_error_code_is_left_to_the_caller(): + import httpx + + from policyengine_api.worker_spm import raise_worker_spm_error + + # An unknown code keeps today's handling: the caller sees the 400 itself. + detail = {"code": "SOME_FUTURE_CODE", "message": "Not a public input error"} + assert ( + raise_worker_spm_error(httpx.Response(400, json={"errors": [detail]})) is None + ) + + def test_variadic_constructor_alone_does_not_claim_support(): class Legacy: def __init__(self, **kwargs): From 054484aa469e21da95df7e75b20b315a75c19b04 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Wed, 28 Oct 2026 01:49:32 +0400 Subject: [PATCH 3/3] Rename the dataset-year polling test to describe its result Co-Authored-By: Claude Opus 5.5 --- tests/unit/routes/test_spm_year_worker_polling.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit/routes/test_spm_year_worker_polling.py b/tests/unit/routes/test_spm_year_worker_polling.py index 9c8a3fc9e..f44668569 100644 --- a/tests/unit/routes/test_spm_year_worker_polling.py +++ b/tests/unit/routes/test_spm_year_worker_polling.py @@ -379,7 +379,7 @@ def test_typed_worker_error_is_terminal_and_replays_after_service_recreation( @pytest.mark.parametrize("budget_window", [False, True], ids=["annual", "window"]) -def test_a_dataset_year_error_is_a_terminal_400_replayed_from_storage( +def test_a_dataset_year_error_returns_400_and_replays_from_storage( monkeypatch, budget_window ): """A year the worker's model cannot project is a caller error, not a 502.