Skip to content

Commit 0266d08

Browse files
karlwaldmanclaude
andauthored
fix(errors): keep a fail-envelope sentence out of error.code (#145) (#148)
error_from_response copied a string data.error into error.code whatever it held. In the render_fail envelope it is either a machine code (sentence in data.message) or the sentence itself, as every fuel-surcharge 400/404 sends, so code-based branching saw a unique free-text string per request. data.error now becomes the code only when it is a snake-case token: upper-snake (VALIDATION_ERROR, INTERVAL_FLOOR, WATCH_LIMIT) or lower-snake (invalid_code, no_price_data, invalid_request, which api_validations.rb and prices_controller.rb send). A sentence stays the message and code is None. A canonical nested error object keeps precedence; redaction is unchanged. Closes #145 Claude-Session: https://claude.ai/code/session_015ao5paex73xXvuM424Libo Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent c6f76ec commit 0266d08

3 files changed

Lines changed: 302 additions & 1 deletion

File tree

‎CHANGELOG.md‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,17 @@ All notable changes to the OilPriceAPI Python SDK will be documented in this fil
7171

7272
### Fixed
7373

74+
- **`error.code` is no longer set to a human sentence (#145).** For fail
75+
envelopes, `{"status": "fail", "data": {"error": ...}}`, the SDK copied
76+
`data.error` into `error.code` / `error.machine_code` whatever it held. Every
77+
fuel-surcharge 400/404 puts the sentence itself there, for example
78+
`"Unknown carrier 'nope'. Covered carriers: ..."`, so code-based branching
79+
saw a different string on every request. `data.error` is now the code only
80+
when it is a snake-case token: upper-snake like `VALIDATION_ERROR` or
81+
`INTERVAL_FLOOR`, or lower-snake like `invalid_code` or `no_price_data`. A
82+
sentence stays in `error.message` and `error.code` is `None`. A canonical
83+
nested `error` object still takes precedence, and API-key redaction is
84+
unchanged.
7485
- **`subscriptions.list()` and `subscriptions.events()` no longer report a
7586
malformed success as "nothing there" (#142), sync and async.** A 200 without
7687
a `data.subscriptions` list returned `[]`, and one without `data.events` /

‎oilpriceapi/exceptions.py‎

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,21 @@ def _first_value(sources: Iterable[Mapping[str, Any]], *keys: str) -> Any:
115115
return None
116116

117117

118+
def _is_machine_code(value: Any) -> bool:
119+
"""True for a snake-case token such as ``VALIDATION_ERROR`` or ``invalid_code``.
120+
121+
The API sends both cases in ``data.error`` (``render_fail``): upper-snake
122+
from the subscription and upgrade-trigger routes, lower-snake from
123+
``api_validations.rb`` and ``prices_controller.rb``. A sentence -- anything
124+
with spaces or punctuation, or a single word -- is not a code.
125+
"""
126+
if not isinstance(value, str) or "_" not in value or not value[:1].isalpha():
127+
return False
128+
if not all(part.isascii() and part.isalnum() for part in value.split("_")):
129+
return False
130+
return value.isupper() or value.islower()
131+
132+
118133
def _number(value: Any) -> Any:
119134
if value is None:
120135
return None
@@ -510,7 +525,11 @@ def error_from_response(
510525
message = str(message_value or raw_text.strip() or f"HTTP {status_code} error")
511526

512527
code_value = _first_value(sources, "code", "error_code", "type")
513-
if code_value is None and isinstance(nested_data_error, str):
528+
# In the fail envelope `data.error` is either a machine code (the sentence is
529+
# in `data.message`) or the sentence itself, as every fuel-surcharge 400/404
530+
# sends. Only a code-shaped value is a code; a sentence stays the message
531+
# and never becomes `error.code` (#145).
532+
if code_value is None and _is_machine_code(nested_data_error):
514533
code_value = nested_data_error
515534
request_id_value = _first_value(
516535
sources,
Lines changed: 271 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,271 @@
1+
"""#145 -- a human sentence in ``data.error`` is a message, never ``error.code``.
2+
3+
The API answers many failures with the JSend fail envelope,
4+
``{"status": "fail", "data": {"error": ..., "message"?: ...}}`` (``render_fail``
5+
in ``V1::BaseController``). ``data.error`` holds one of two things:
6+
7+
* a machine code, with the sentence in ``data.message``: ``VALIDATION_ERROR``,
8+
``INTERVAL_FLOOR``, ``WATCH_LIMIT``, and the lower-snake ``invalid_code``,
9+
``no_price_data`` and ``invalid_request`` that ``api_validations.rb`` and
10+
``prices_controller.rb`` send (origin/main, 2026-09-13);
11+
* the sentence itself, as every ``V1::FuelSurchargeController`` 400/404 does.
12+
13+
``error_from_response`` copied either one into ``error.code``, so a caller
14+
branching on ``code`` saw a unique free-text string per request.
15+
16+
Bodies marked "live" are verbatim from ``api.oilpriceapi.com`` on 2026-09-13.
17+
Client tests drive the REAL sync and async clients over a mocked transport.
18+
"""
19+
20+
import asyncio
21+
from unittest.mock import AsyncMock, patch
22+
23+
import httpx
24+
import pytest
25+
26+
from oilpriceapi import AsyncOilPriceAPI, OilPriceAPI
27+
from oilpriceapi.exceptions import (
28+
AuthenticationError,
29+
BadRequestError,
30+
DataNotFoundError,
31+
OilPriceAPIError,
32+
PaymentRequiredError,
33+
PermissionDeniedError,
34+
RateLimitError,
35+
ServerError,
36+
ValidationError,
37+
error_from_response,
38+
)
39+
40+
# Not a credential: a fixture string. Every request here is mocked.
41+
FIXTURE_KEY = "-".join(["fixture", "not", "a", "real", "key"])
42+
43+
MODES = ["sync", "async"]
44+
45+
# live: GET /v1/fuel-surcharge/nope/latest
46+
UNKNOWN_CARRIER_404 = {
47+
"status": "fail",
48+
"data": {
49+
"error": "Unknown carrier 'nope'. Covered carriers: odfl, saia, estes, xpo, abf, tforce, averitt, southeastern-freight.",
50+
"covered_carriers": ["odfl", "saia", "estes", "xpo", "abf", "tforce", "averitt", "southeastern-freight"],
51+
"hint": "Call GET /v1/fuel-surcharge to list every covered carrier with its latest surcharge.",
52+
},
53+
}
54+
55+
# live: POST /v1/subscriptions {"codes": [], "interval_seconds": 86400}
56+
CODES_BLANK_422 = {
57+
"status": "fail",
58+
"data": {
59+
"error": "VALIDATION_ERROR",
60+
"message": "Codes can't be blank",
61+
"details": {"codes": ["can't be blank"]},
62+
},
63+
}
64+
65+
# live: POST /v1/subscriptions {"interval_seconds": -5}; `upgrade.plans` trimmed to one entry.
66+
INTERVAL_FLOOR_402 = {
67+
"status": "fail",
68+
"data": {
69+
"error": "INTERVAL_FLOOR",
70+
"message": "Your plan's minimum snapshot interval is 60s (requested -5s). Upgrade to poll faster.",
71+
"limit": 60,
72+
"current": -5,
73+
"upgrade_trigger": "interval_floor",
74+
"upgrade_url": "https://www.oilpriceapi.com/pricing?plan=enterprise_v2&utm_source=api&utm_medium=agent&utm_campaign=agent_min_interval",
75+
"upgrade": {
76+
"url": "https://www.oilpriceapi.com/pricing?plan=enterprise_v2&utm_source=api&utm_medium=agent&utm_campaign=agent_min_interval",
77+
"next_tier": "enterprise_v2",
78+
"plans": [{"plan": "developer", "requests": 10000, "watches": 3}],
79+
},
80+
},
81+
}
82+
83+
# live: GET /v1/subscriptions/<unknown uuid>
84+
NOT_FOUND_404 = {
85+
"error": {
86+
"code": "NOT_FOUND",
87+
"message": "Subscription not found",
88+
"status": 404,
89+
"request_id": "6598da0c-4289-470f-84f3-b4c3d56198b3",
90+
"docs": "https://docs.oilpriceapi.com#NOT_FOUND",
91+
}
92+
}
93+
94+
# live: GET /v1/prices/latest?by_code=NOT_A_REAL_CODE_XYZ (lower-snake code + sentence,
95+
# app/controllers/concerns/api_validations.rb).
96+
INVALID_CODE_400 = {
97+
"status": "fail",
98+
"data": {
99+
"error": "invalid_code",
100+
"message": "Code 'NOT_A_REAL_CODE_XYZ' not found. No close match found. See /v1/commodities for all available codes.",
101+
"suggestions": [],
102+
"did_you_mean": [],
103+
"invalid_codes": ["NOT_A_REAL_CODE_XYZ"],
104+
"all_codes_url": "https://api.oilpriceapi.com/v1/commodities",
105+
},
106+
}
107+
108+
109+
def _response(status, body):
110+
request = httpx.Request(
111+
"GET",
112+
"https://api.oilpriceapi.com/v1/fuel-surcharge/nope/latest",
113+
headers={"Authorization": f"Token {FIXTURE_KEY}"},
114+
)
115+
return httpx.Response(status, json=body, request=request)
116+
117+
118+
def _raised(mode, status, body):
119+
"""Return the error the real client raises for ``body``."""
120+
response = _response(status, body)
121+
if mode == "sync":
122+
with OilPriceAPI(api_key=FIXTURE_KEY, max_retries=1) as client:
123+
with patch.object(client._client, "request", return_value=response):
124+
with pytest.raises(OilPriceAPIError) as info:
125+
client.request("GET", "/v1/fuel-surcharge/nope/latest")
126+
return info.value
127+
128+
async def go():
129+
async with AsyncOilPriceAPI(api_key=FIXTURE_KEY, max_retries=1) as client:
130+
with patch.object(client._client, "request", new=AsyncMock(return_value=response)):
131+
with pytest.raises(OilPriceAPIError) as info:
132+
await client.request("GET", "/v1/fuel-surcharge/nope/latest")
133+
return info.value
134+
135+
return asyncio.run(go())
136+
137+
138+
# --- the defect ----------------------------------------------------------------
139+
140+
141+
@pytest.mark.parametrize("mode", MODES)
142+
def test_sentence_in_fail_envelope_is_the_message_not_the_code(mode):
143+
error = _raised(mode, 404, UNKNOWN_CARRIER_404)
144+
145+
assert isinstance(error, DataNotFoundError)
146+
assert error.code is None
147+
assert error.machine_code is None
148+
assert error.message == UNKNOWN_CARRIER_404["data"]["error"]
149+
assert error.suggestions == UNKNOWN_CARRIER_404["data"]["covered_carriers"]
150+
assert error.raw_body == UNKNOWN_CARRIER_404
151+
152+
153+
@pytest.mark.parametrize(
154+
("status", "expected_type"),
155+
[
156+
(400, BadRequestError),
157+
(401, AuthenticationError),
158+
(402, PaymentRequiredError),
159+
(403, PermissionDeniedError),
160+
(404, DataNotFoundError),
161+
(422, ValidationError),
162+
(429, RateLimitError),
163+
(503, ServerError),
164+
],
165+
)
166+
def test_no_status_class_turns_a_sentence_into_a_code(status, expected_type):
167+
body = {"status": "fail", "data": {"error": "period is not supported by the latest endpoint; use /v1/rig-counts/historical"}}
168+
169+
error = error_from_response(_response(status, body))
170+
171+
assert isinstance(error, expected_type)
172+
assert error.status_code == status
173+
assert error.code is None
174+
assert error.message == body["data"]["error"]
175+
176+
177+
def test_sentence_without_the_status_key_is_not_a_code_either():
178+
body = {"data": {"error": "Parcel fuel-surcharge history requires a service_level parameter."}}
179+
180+
error = error_from_response(_response(400, body))
181+
182+
assert error.code is None
183+
assert error.message == body["data"]["error"]
184+
185+
186+
# --- what must not change ------------------------------------------------------
187+
188+
189+
@pytest.mark.parametrize("mode", MODES)
190+
def test_upper_snake_code_is_the_code_and_message_is_the_sentence(mode):
191+
error = _raised(mode, 422, CODES_BLANK_422)
192+
193+
assert isinstance(error, ValidationError)
194+
assert error.status_code == 422
195+
assert error.code == "VALIDATION_ERROR"
196+
assert error.machine_code == "VALIDATION_ERROR"
197+
assert error.message == "Codes can't be blank"
198+
199+
200+
@pytest.mark.parametrize("mode", MODES)
201+
def test_upgrade_trigger_keeps_code_message_and_remediation(mode):
202+
error = _raised(mode, 402, INTERVAL_FLOOR_402)
203+
204+
assert isinstance(error, PaymentRequiredError)
205+
assert error.code == "INTERVAL_FLOOR"
206+
assert error.message.startswith("Your plan's minimum snapshot interval is 60s")
207+
assert error.remediation_url == INTERVAL_FLOOR_402["data"]["upgrade_url"]
208+
209+
210+
@pytest.mark.parametrize("mode", MODES)
211+
def test_lower_snake_code_the_api_sends_stays_the_code(mode):
212+
error = _raised(mode, 400, INVALID_CODE_400)
213+
214+
assert isinstance(error, BadRequestError)
215+
assert error.code == "invalid_code"
216+
assert error.message == INVALID_CODE_400["data"]["message"]
217+
assert error.invalid_codes == ["NOT_A_REAL_CODE_XYZ"]
218+
219+
220+
def test_code_with_no_sentence_is_both_code_and_message():
221+
error = error_from_response(_response(402, {"status": "fail", "data": {"error": "WATCH_LIMIT"}}))
222+
223+
assert error.code == "WATCH_LIMIT"
224+
assert error.message == "WATCH_LIMIT"
225+
226+
227+
@pytest.mark.parametrize("mode", MODES)
228+
def test_canonical_nested_error_object_keeps_precedence(mode):
229+
error = _raised(mode, 404, NOT_FOUND_404)
230+
231+
assert isinstance(error, DataNotFoundError)
232+
assert error.code == "NOT_FOUND"
233+
assert error.message == "Subscription not found"
234+
assert error.request_id == "6598da0c-4289-470f-84f3-b4c3d56198b3"
235+
236+
237+
def test_error_object_nested_under_fail_data_keeps_precedence():
238+
body = {"status": "fail", "data": {"error": {"code": "DATA_NOT_AVAILABLE", "message": "Not yet published"}}}
239+
240+
error = error_from_response(_response(404, body))
241+
242+
assert error.code == "DATA_NOT_AVAILABLE"
243+
assert error.message == "Not yet published"
244+
245+
246+
# --- redaction is unchanged ----------------------------------------------------
247+
248+
249+
@pytest.mark.parametrize("mode", MODES)
250+
def test_key_in_a_fail_sentence_is_redacted_and_not_used_as_code(mode):
251+
body = {"status": "fail", "data": {"error": f"API key {FIXTURE_KEY} cannot use this route"}}
252+
253+
error = _raised(mode, 403, body)
254+
255+
assert error.code is None
256+
assert error.message == "API key [REDACTED] cannot use this route"
257+
for rendered in (error.message, str(error), repr(error.raw_body), error.raw_text):
258+
assert FIXTURE_KEY not in rendered
259+
260+
261+
@pytest.mark.parametrize("mode", MODES)
262+
def test_key_in_a_fail_message_is_redacted_beside_a_machine_code(mode):
263+
body = {"status": "fail", "data": {"error": "VALIDATION_ERROR", "message": f"Token {FIXTURE_KEY} rejected"}}
264+
265+
error = _raised(mode, 422, body)
266+
267+
assert error.code == "VALIDATION_ERROR"
268+
# main redacts the whole "Token <key>" header value first; unchanged here.
269+
assert error.message == "[REDACTED] rejected"
270+
for rendered in (error.message, str(error), repr(error.raw_body), error.raw_text):
271+
assert FIXTURE_KEY not in rendered

0 commit comments

Comments
 (0)