Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/unsupported-economy-year.fixed.md
Original file line number Diff line number Diff line change
@@ -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.
10 changes: 3 additions & 7 deletions policyengine_api/country.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down
35 changes: 35 additions & 0 deletions policyengine_api/economy_years.py
Original file line number Diff line number Diff line change
@@ -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))
7 changes: 6 additions & 1 deletion policyengine_api/spm.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down
37 changes: 26 additions & 11 deletions policyengine_api/worker_spm.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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"])
78 changes: 78 additions & 0 deletions tests/unit/routes/test_spm_year_worker_polling.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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
Expand Down
58 changes: 58 additions & 0 deletions tests/unit/test_country.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""

Expand All @@ -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 = [
Expand Down
30 changes: 30 additions & 0 deletions tests/unit/test_spm_settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
Loading