Skip to content

Commit ebafc9d

Browse files
karlwaldmanclaude
andcommitted
fix(url,retry,futures): un-break today's three regressions (#119, #118, #122)
All three shipped today and all three are reachable from the SDK's own code. #119 -- `_url.py` rejected U+0020 SPACE. `range(0x21)` is 0x00-0x20 INCLUSIVE; the comment above it names CR/LF request splitting and NUL, all below 0x20, so the range over-reached by exactly one character. `/v1/prices?name=Brent Crude` raised where it previously worked, and `resources/demo.py` builds exactly that shape (`path = f"{path}?codes={','.join(codes)}"`). A space cannot introduce an authority or split a request line -- httpx percent-encodes it -- so nothing the guard exists to stop is enabled by allowing it. Now `range(0x20)`; 0x00-0x1F, 0x7F and backslash stay refused, pinned by tests. #118 -- #115 keyed replay safety on the HTTP method, which is right, but no audit of this SDK's own call sites was done, so its POST-shaped READS lost all retries. A station lookup is idempotent by construction: same lat/lng/radius, same answer, nothing created. Removing its retries was a pure availability regression -- a call that used to ride out one bad gateway response now fails on the first blip. The rule used to classify them: a POST is read-shaped when the endpoint creates or mutates no server-side resource AND the same request sent twice returns the same answer. Six call sites qualify and now pass `idempotent=True` -- diesel `get_stations`, `webhooks.test`, `alerts.test`, each in both clients. `data_sources.test` deliberately does NOT: it triggers a fetch against the customer's configured source and can write to its ingest log. `create()` and `rotate_credentials()` are unchanged and still sent exactly once. `test_every_post_call_site_declares_intent` walks the package with `ast` and fails until every `method="POST"` call either passes `idempotent=` or is listed as a known write with a reason, so the next POST-shaped read cannot quietly lose its retries the same way. #122 -- `normalize_futures_slug` raised a builtin `ValueError`, outside the SDK hierarchy. That predates #111, but #111 turned 18 live catalog codes from "resolved to a DIFFERENT instrument" into "raises" -- the right call -- so user code written as `except OilPriceAPIError: fall_back()` went from a silently wrong answer to an uncaught crash. #111's own comment argues a refusal is recoverable; it is only recoverable if it is catchable. New `FuturesContractError(ValidationError, ValueError)`: catchable through the documented base class, and still a `ValueError` so existing callers are unaffected. It renders its own message, which lists every valid slug and contract code, so the guidance survives regardless of #117's merge order. Tests: tests/unit/test_url_allows_space.py (10), tests/unit/test_read_shaped_post_retries.py (12), tests/unit/test_futures_contract_error.py (26). Proven red against `origin/main` source: 28 failed / 23 passed. Suite: 112 failed / 681 passed / 63 skipped, against a measured baseline of 112 failed / 630 passed / 63 skipped on clean main (the 112 are pre-existing, all missing pytest-asyncio and respx in the environment). Note for reviewers: `resources/alerts.py` and `resources/diesel.py` are CRLF files in this repo. Their line endings are preserved -- the diff is 5 and 6 lines, not the whole file. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015ao5paex73xXvuM424Libo
1 parent fcc4edf commit ebafc9d

11 files changed

Lines changed: 501 additions & 9 deletions

‎oilpriceapi/__init__.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
RateLimitError,
2626
ServerError,
2727
TimeoutError,
28+
FuturesContractError,
2829
ValidationError,
2930
)
3031
from oilpriceapi.models import (
@@ -60,6 +61,7 @@
6061
"RateLimitError",
6162
"DataNotFoundError",
6263
"ServerError",
64+
"FuturesContractError",
6365
"ValidationError",
6466
"NetworkError",
6567
"TimeoutError",

‎oilpriceapi/_url.py‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,12 @@
3333
# reference after this SDK has already decided it was a plain path.
3434
# Control characters (including CR/LF and NUL) can split a request line or
3535
# smuggle a header. Neither can appear in a legitimate API path.
36-
_FORBIDDEN_CHARS = frozenset("\\") | frozenset(chr(c) for c in range(0x21)) | {chr(0x7F)}
36+
#
37+
# The range stops BELOW 0x20: U+0020 SPACE is legitimate in a path or an inline
38+
# query string, httpx percent-encodes it, and it cannot introduce an authority
39+
# or split a request line. `range(0x21)` forbade it and broke every caller who
40+
# built a query inline -- including this SDK's own demo resource (#119).
41+
_FORBIDDEN_CHARS = frozenset("\\") | frozenset(chr(c) for c in range(0x20)) | {chr(0x7F)}
3742

3843
_DEFAULT_PORTS = {"http": 80, "https": 443}
3944

‎oilpriceapi/async_resources.py‎

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -61,9 +61,13 @@ async def get_stations(self, lat: float, lng: float, radius: Optional[float] = 8
6161
raise ValidationError(
6262
message="Radius must be between 0 and 50000 meters", field="radius", value=radius
6363
)
64+
# Read-shaped POST: a lat/lng/radius query that creates nothing, so
65+
# repeating it has the same effect as doing it once and it keeps the
66+
# retries #115 removed from genuine writes (#118).
6467
response = await self.client.request(
6568
method="POST", path="/v1/diesel-prices/stations",
66-
json_data={"lat": lat, "lng": lng, "radius": radius}
69+
json_data={"lat": lat, "lng": lng, "radius": radius},
70+
idempotent=True
6771
)
6872
return DieselStationsResponse(**response)
6973

@@ -263,7 +267,11 @@ async def test(self, alert_id: str) -> Dict[str, Any]:
263267
raise ValidationError(
264268
message="Alert ID must be a non-empty string", field="alert_id", value=alert_id
265269
)
266-
response = await self.client.request(method="POST", path=f"/v1/alerts/{alert_id}/test")
270+
# Read-shaped POST: a simulated trigger that does not count against
271+
# trigger limits and creates nothing, so it keeps its retries (#118).
272+
response = await self.client.request(
273+
method="POST", path=f"/v1/alerts/{alert_id}/test", idempotent=True
274+
)
267275
if "data" in response:
268276
return response["data"]
269277
return response
@@ -1440,7 +1448,11 @@ async def delete(self, webhook_id: str) -> None:
14401448
await self.client.request(method="DELETE", path=f"/v1/webhooks/{webhook_id}")
14411449

14421450
async def test(self, webhook_id: str) -> Dict[str, Any]:
1443-
response = await self.client.request(method="POST", path=f"/v1/webhooks/{webhook_id}/test")
1451+
# Read-shaped POST: a diagnostic against an already-configured
1452+
# webhook. It creates nothing, so it keeps its retries (#118).
1453+
response = await self.client.request(
1454+
method="POST", path=f"/v1/webhooks/{webhook_id}/test", idempotent=True
1455+
)
14441456
if "data" in response:
14451457
return response["data"]
14461458
return response

‎oilpriceapi/exceptions.py‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -342,6 +342,27 @@ def __str__(self) -> str:
342342
return message
343343

344344

345+
class FuturesContractError(ValidationError, ValueError):
346+
"""Raised when a futures contract or slug cannot be resolved (#122).
347+
348+
Two base classes, deliberately:
349+
350+
* ``ValidationError`` -- so ``except OilPriceAPIError`` catches it. Every
351+
documented recovery path in this SDK is written against that base class,
352+
and #111 turned 18 live catalog codes from "resolved to a DIFFERENT
353+
instrument" into "raises". A refusal is only recoverable if it is
354+
catchable, and a bare builtin ``ValueError`` was not.
355+
* ``ValueError`` -- so code written against the pre-#111 ``raise
356+
ValueError`` keeps working. This is not a breaking change.
357+
"""
358+
359+
def __str__(self) -> str:
360+
# The message lists every valid slug and contract code: it IS the
361+
# remediation, so render it whole rather than letting a field/value
362+
# summary stand in for it.
363+
return self.message
364+
365+
345366
class ServerError(OilPriceAPIError):
346367
"""Raised when the server returns HTTP 5xx."""
347368

‎oilpriceapi/resources/_futures_slug.py‎

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@
2424

2525
from typing import Dict, Set
2626

27+
from ..exceptions import FuturesContractError
28+
2729
# Canonical slugs emitted by the SDK for latest-curve routes.
2830
VALID_SLUGS: Set[str] = {
2931
"brent",
@@ -145,7 +147,14 @@ def normalize_futures_slug(contract: str) -> str:
145147

146148
valid = ", ".join(sorted(VALID_SLUGS))
147149
codes = ", ".join(sorted(CONTRACT_CODE_TO_SLUG))
148-
raise ValueError(
150+
# FuturesContractError subclasses both ValidationError -- so the SDK's
151+
# documented `except OilPriceAPIError` catches it -- and ValueError, so
152+
# callers written against the old bare `raise ValueError` are unaffected.
153+
# #111 made this path far more common: it turned 18 live catalog codes from
154+
# a wrong answer into a refusal, and a refusal must be catchable (#122).
155+
raise FuturesContractError(
149156
f"Unknown futures contract/slug {contract!r}. "
150-
f"Pass a slug ({valid}) or a contract code ({codes})."
157+
f"Pass a slug ({valid}) or a contract code ({codes}).",
158+
field="contract",
159+
value=contract,
151160
)

‎oilpriceapi/resources/alerts.py‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -535,9 +535,12 @@ def test(self, alert_id: str) -> Dict[str, Any]:
535535
value=alert_id
536536
)
537537

538+
# Read-shaped POST: a simulated trigger that does not count against
539+
# trigger limits and creates nothing, so it keeps its retries (#118).
538540
response = self.client.request(
539541
method="POST",
540-
path=f"/v1/alerts/{alert_id}/test"
542+
path=f"/v1/alerts/{alert_id}/test",
543+
idempotent=True
541544
)
542545

543546
# Parse response

‎oilpriceapi/resources/diesel.py‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -216,14 +216,18 @@ def get_stations(
216216
)
217217

218218
# Make API request (POST method for stations endpoint)
219+
# Read-shaped POST: a lat/lng/radius query that creates nothing, so
220+
# repeating it has the same effect as doing it once and it keeps the
221+
# retries #115 removed from genuine writes (#118).
219222
response = self.client.request(
220223
method="POST",
221224
path="/v1/diesel-prices/stations",
222225
json_data={
223226
"lat": lat,
224227
"lng": lng,
225228
"radius": radius
226-
}
229+
},
230+
idempotent=True
227231
)
228232

229233
# Same envelope as get_price: the stations block lives under `data`.

‎oilpriceapi/resources/webhooks.py‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -214,9 +214,12 @@ def test(self, webhook_id: str) -> Dict[str, Any]:
214214
>>> print(f"Test status: {result['status']}")
215215
>>> print(f"Response: {result['response']}")
216216
"""
217+
# Read-shaped POST: a diagnostic against an already-configured
218+
# webhook. It creates nothing, so it keeps its retries (#118).
217219
response = self.client.request(
218220
method="POST",
219-
path=f"/v1/webhooks/{webhook_id}/test"
221+
path=f"/v1/webhooks/{webhook_id}/test",
222+
idempotent=True
220223
)
221224

222225
# Parse response
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
"""An unresolvable futures contract must raise inside the SDK's hierarchy (#122).
2+
3+
`normalize_futures_slug` raised a builtin `ValueError`, which is not an
4+
`OilPriceAPIError`. That predates #111, but #111 turned 18 live catalog codes
5+
from "resolved to a DIFFERENT instrument" into "raises" -- the right call -- so
6+
user code shaped like
7+
8+
try:
9+
curve = client.futures.curve(code)
10+
except OilPriceAPIError:
11+
fall_back()
12+
13+
went from a silently wrong answer to an uncaught crash. A refusal is only
14+
recoverable if it is catchable through the documented base class.
15+
"""
16+
17+
import pytest
18+
19+
from oilpriceapi import exceptions
20+
from oilpriceapi.exceptions import OilPriceAPIError, ValidationError
21+
from oilpriceapi.resources._futures_slug import normalize_futures_slug
22+
from oilpriceapi.resources.futures import FuturesResource
23+
24+
# The catalog codes #111 moved from "wrong instrument" to "raises".
25+
REWRITTEN_BY_111 = [
26+
"LNG_NW_EUROPE_EUR",
27+
"WTI_MIDLAND_USD",
28+
"BRENT_CRUDE_USD",
29+
"GASOIL_USD",
30+
"JKM_LNG_USD",
31+
]
32+
33+
34+
class _FakeClient:
35+
def request(self, **kwargs): # pragma: no cover - never reached
36+
raise AssertionError("a refused contract must not reach the transport")
37+
38+
39+
@pytest.mark.parametrize("code", REWRITTEN_BY_111)
40+
def test_normalize_raises_inside_the_sdk_hierarchy(code):
41+
with pytest.raises(OilPriceAPIError):
42+
normalize_futures_slug(code)
43+
44+
45+
@pytest.mark.parametrize("code", REWRITTEN_BY_111)
46+
def test_it_is_a_validation_error_carrying_the_contract(code):
47+
with pytest.raises(exceptions.FuturesContractError) as excinfo:
48+
normalize_futures_slug(code)
49+
50+
error = excinfo.value
51+
assert isinstance(error, ValidationError)
52+
assert error.field == "contract"
53+
assert error.value == code
54+
55+
56+
@pytest.mark.parametrize("code", REWRITTEN_BY_111)
57+
def test_value_error_callers_still_catch_it(code):
58+
"""Backwards compatible: code written against the old `raise ValueError`."""
59+
with pytest.raises(ValueError):
60+
normalize_futures_slug(code)
61+
62+
63+
def test_the_helpful_message_survives_str():
64+
"""The message IS the remediation: it lists every valid slug and code."""
65+
with pytest.raises(exceptions.FuturesContractError) as excinfo:
66+
normalize_futures_slug("WTI_MIDLAND_USD")
67+
68+
rendered = str(excinfo.value)
69+
assert "Unknown futures contract/slug 'WTI_MIDLAND_USD'" in rendered
70+
assert "Pass a slug (" in rendered
71+
assert "brent" in rendered
72+
assert "CL" in rendered
73+
74+
75+
@pytest.mark.parametrize("method", ["latest", "curve", "intraday", "historical", "ohlc"])
76+
def test_public_futures_methods_do_not_escape_the_base_class(method):
77+
resource = FuturesResource(_FakeClient())
78+
79+
with pytest.raises(OilPriceAPIError):
80+
getattr(resource, method)("WTI_MIDLAND_USD")
81+
82+
83+
@pytest.mark.parametrize("contract", ["brent", "wti", "CL", "BZ", "continuous/brent"])
84+
def test_valid_contracts_are_untouched(contract):
85+
assert normalize_futures_slug(contract)

0 commit comments

Comments
 (0)