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/routes/test_spm_year_worker_polling.py b/tests/unit/routes/test_spm_year_worker_polling.py index 17f3bd203..f44668569 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_returns_400_and_replays_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..6ca8cf06e 100644 --- a/tests/unit/test_spm_settings.py +++ b/tests/unit/test_spm_settings.py @@ -319,6 +319,36 @@ 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_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):