Skip to content
Closed
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/calculate-malformed-payload-400.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Return 400 instead of 500 when a calculate payload fails situation parsing.
24 changes: 22 additions & 2 deletions policyengine_api/endpoints/household.py
Original file line number Diff line number Diff line change
Expand Up @@ -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():
Expand Down Expand Up @@ -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"]
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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(
Expand Down
99 changes: 99 additions & 0 deletions tests/unit/endpoints/test_calculate_error_statuses.py
Original file line number Diff line number Diff line change
@@ -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(
"/<country_id>/calculate-full",
"calculate_full",
handler,
methods=["POST"],
)
else:
app.add_url_rule(
"/<country_id>/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"]
Loading