diff --git a/changelog.d/calculate-malformed-payload-400.fixed.md b/changelog.d/calculate-malformed-payload-400.fixed.md new file mode 100644 index 000000000..1a63c6479 --- /dev/null +++ b/changelog.d/calculate-malformed-payload-400.fixed.md @@ -0,0 +1 @@ +Return 400 instead of 500 when a calculate payload fails situation parsing. diff --git a/policyengine_api/endpoints/household.py b/policyengine_api/endpoints/household.py index 24e14661d..314871a00 100644 --- a/policyengine_api/endpoints/household.py +++ b/policyengine_api/endpoints/household.py @@ -10,6 +10,7 @@ format_unrecognized_inputs_message, ) from policyengine_api.utils.payload_validators import validate_country +from policyengine_core.errors import SituationParsingError def get_countries(): @@ -45,7 +46,11 @@ def add_yearly_variables(household, country_id, countries=None): if variables[variable]["isInputVariable"]: household[entity_plural][entity][ variables[variable]["name"] - ] = {household_year: variables[variable]["defaultValue"]} + ] = { + household_year: variables[variable][ + "defaultValue" + ] + } else: household[entity_plural][entity][ variables[variable]["name"] @@ -97,7 +102,9 @@ def get_household_year(household): @validate_country -def get_household_under_policy(country_id: str, household_id: str, policy_id: str): +def get_household_under_policy( + country_id: str, household_id: str, policy_id: str +): """Get a household's output data under a given policy. Args: @@ -274,6 +281,19 @@ def get_calculate(country_id: str, add_missing: bool = False) -> dict: try: result = country.calculate(household_json, policy_json) + except SituationParsingError as e: + # Malformed household payloads (e.g. a dict where a number belongs) + # are client errors, not server errors — mostly bot traffic. + response_body = dict( + status="error", + message=f"Invalid household payload: {e}", + result=None, + ) + return Response( + json.dumps(response_body), + status=400, + mimetype="application/json", + ) except Exception as e: logging.exception(e) response_body = dict( diff --git a/tests/unit/endpoints/test_calculate_error_statuses.py b/tests/unit/endpoints/test_calculate_error_statuses.py new file mode 100644 index 000000000..d37d72868 --- /dev/null +++ b/tests/unit/endpoints/test_calculate_error_statuses.py @@ -0,0 +1,99 @@ +from flask import Flask +import pytest +from policyengine_core.errors import SituationParsingError + +from policyengine_api.endpoints import household as household_endpoint + +from .test_calculate_deprecated_inputs import DummyCountry + +HOUSEHOLD = { + "people": { + "you": { + "age": {"2026": 40}, + "employment_income": {"2026": 30_000}, + } + } +} + + +class ParsingErrorCountry(DummyCountry): + def calculate(self, household, policy): + raise SituationParsingError( + ["people", "you", "employment_income", "2026"], + "Can't deal with value: expected type number, received '{}'.", + ) + + +class CrashingCountry(DummyCountry): + def calculate(self, household, policy): + raise RuntimeError("engine exploded") + + +def make_client(monkeypatch, country, add_missing=False): + monkeypatch.setattr( + household_endpoint, + "get_countries", + lambda: {"us": country}, + ) + app = Flask(__name__) + if add_missing: + monkeypatch.setattr( + household_endpoint, + "add_yearly_variables", + lambda household, country_id: household, + ) + + def handler(country_id): + return household_endpoint.get_calculate( + country_id, add_missing=True + ) + + app.add_url_rule( + "//calculate-full", + "calculate_full", + handler, + methods=["POST"], + ) + else: + app.add_url_rule( + "//calculate", + "calculate", + household_endpoint.get_calculate, + methods=["POST"], + ) + return app.test_client() + + +def test__calculate__returns_400_on_situation_parsing_error(monkeypatch): + client = make_client(monkeypatch, ParsingErrorCountry()) + + response = client.post("/us/calculate", json={"household": HOUSEHOLD}) + + assert response.status_code == 400 + payload = response.get_json() + assert payload["status"] == "error" + assert payload["result"] is None + assert payload["message"].startswith("Invalid household payload") + + +def test__calculate_full__returns_400_on_situation_parsing_error(monkeypatch): + client = make_client(monkeypatch, ParsingErrorCountry(), add_missing=True) + + response = client.post("/us/calculate-full", json={"household": HOUSEHOLD}) + + assert response.status_code == 400 + payload = response.get_json() + assert payload["status"] == "error" + assert payload["result"] is None + assert payload["message"].startswith("Invalid household payload") + + +def test__calculate__returns_500_on_unexpected_error(monkeypatch): + client = make_client(monkeypatch, CrashingCountry()) + + response = client.post("/us/calculate", json={"household": HOUSEHOLD}) + + assert response.status_code == 500 + payload = response.get_json() + assert payload["status"] == "error" + assert "engine exploded" in payload["message"]