Read Problem Details without turning an error message into a decision rule
The short answer
Use the received HTTP status for the error class; retain a conflicting JSON status as a contract discrepancy. Do not choose an action from a title.
Start with the reader’s decision
A read-only client needs to present a failed request intelligibly. That does not immediately require a retry engine. The first decision is narrower: is this a request-side or service-side error class, is there a recognizable problem type, and do the response layers disagree? Keep those outcomes separate. A friendly title must not silently promote an error to success.
RFC 9457 makes body status advisory and requires a known member with the wrong value type to be ignored as though absent.[1] Our local presentation rule uses the received HTTP status for the broad error class. A correctly typed but conflicting body status produces a contract flag; the code does not identify who caused the discrepancy.
A card with four independent fields
- Received HTTP status: the transport observation, not a recalculated value.
- Representation: only application/problem+json enters this problem branch.
- Problem type: a machine identity; a missing type becomes about:blank here.
- Discrepancy: record a difference from the advisory status without logging the entire error text.
A title can be translated or editorially changed. Do not dispatch on the text Temporary. Keep a generic error display available for another representation. Readable explanation can help the user, but the exercise below deliberately does not retain it or invent a provider-specific recovery action.
Run the contradictory fixture
The complete Python example uses already decoded invented JSON objects and sends nothing. Its input combines HTTP 400 with body status 503. Fixtures ignore null, "400", and True as body status, together with type:true; ignored_members retains only member names as a diagnostic. Missing or wrongly typed type falls back to about:blank. RFC 9457 calls status a JSON number: 400.0 and 503.0 are therefore compared numerically, not rejected as wrong types.[1]
Use Copy code to copy the complete code exactly. Manual selection may omit the final newline.
import json
def decide(http_status, media_type, body):
if type(http_status) is not int or http_status not in range(400, 600):
raise ValueError("expected an HTTP error status")
if media_type.split(";", 1)[0].strip().lower() != "application/problem+json":
return {"state": "generic_error", "http_status": http_status}
if type(body) is not dict:
raise ValueError("problem must be an object")
types = {"type": (str,), "status": (int, float), "title": (str,),
"detail": (str,), "instance": (str,)}
ignored = sorted(k for k, allowed in types.items()
if k in body and type(body[k]) not in allowed)
members = {k: v for k, v in body.items() if k not in ignored}
declared = members.get("status")
return {"state": "request_issue" if http_status in range(400, 500) else "service_issue",
"http_status": http_status, "type": members.get("type", "about:blank"),
"status_mismatch": declared is not None and declared != http_status,
"ignored_members": ignored}
# Invented already-decoded JSON objects; no HTTP requests.
example = decide(400, "application/problem+json", {"status": 503, "title": "Temporary"})
assert example["state"] == "request_issue" and example["status_mismatch"] is True
assert decide(503, "text/html", None)["state"] == "generic_error"
assert decide(503, "application/problem+json", {})["type"] == "about:blank"
for bad in [None, "400", True]:
result = decide(400, "application/problem+json", {"status": bad, "type": True})
assert result["ignored_members"] == ["status", "type"]
assert result["type"] == "about:blank" and result["status_mismatch"] is False
assert decide(400, "application/problem+json", {"status": 400.0})["status_mismatch"] is False
assert decide(400, "application/problem+json", {"status": 503.0})["status_mismatch"] is True
try:
decide(True, "application/problem+json", {})
except ValueError:
pass
else:
raise AssertionError("boolean transport status accepted")
print(json.dumps({"example": example, "ignored_member_cases": 3,
"numeric_status_cases": 2, "transport_rejections": 1}, sort_keys=True))
{"example": {"http_status": 400, "ignored_members": [], "state": "request_issue", "status_mismatch": true, "type": "about:blank"}, "ignored_member_cases": 3, "numeric_status_cases": 2, "transport_rejections": 1}
This is the literal locally produced output. The request_issue state follows HTTP 400; status_mismatch remains visible. In the executed assertion, the HTML branch produced generic_error. None of these results explains why a real service failed. A status class is neither a diagnosis nor permission to retry.
What this card does not cover
The code does not validate the complete Problem Details specification, URI syntax, extension fields, or a network response. Only already decoded JSON with finite int/float numbers fits this Python profile; valid numeric HTTP codes in the body are not separately validated. Transport status must locally be an integer in 400–599: True is rejected. This is an error-display profile, not an RFC range restriction. Another representation produces generic_error; a non-object in the problem branch raises an input error, not a tested transport fallback. Wrongly typed title, detail, and instance are also ignored; their contents are not displayed. The type value is neither resolved nor fetched. Authorization, rate limiting, retry, and exact import reconciliation remain separate workflows. Source research and fixtures were executed; source use and independent factual and translation review remain open.
Sources & evidence
- Original source title: RFC 9457 — Problem Details for HTTP APIs
Accessed: 2026-10-18T22:58:29.733925+00:00