Skip to content

Commit c6f76ec

Browse files
karlwaldmanclaude
andauthored
fix(subscriptions): refuse malformed list/events 200s and replaying cursors (#142) (#147)
subscriptions.list() and events(), sync and async, unwrapped with data.get("subscriptions", []) / data.get("events", []) / data.get("cursor"), so a 200 without those keys read as "no subscriptions" / "no new events" with cursor=None. Fed back as events(since=page.cursor), None dropped since; the API reads params[:since].to_i, so the poller replayed every event. - unwrap_subscription_list / unwrap_events_page in _subscriptions_common, shared by both clients, raise OilPriceAPIError(MALFORMED_RESPONSE) with the raw body for a missing or mistyped collection, an invalid record, a non-integer or negative cursor, a non-boolean has_more, or a cursor behind since or behind an event in the page. They reuse _malformed from _fuel_surcharge_common. An empty list or page the API actually sent is still an empty success. - validate_since refuses since values the API reads as 0 or truncates ("abc", "", "41", -1, 1.5, True) with ValidationError(field="since", status_code=None) before any request. Closes #142 Claude-Session: https://claude.ai/code/session_015ao5paex73xXvuM424Libo Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent a5304b3 commit c6f76ec

5 files changed

Lines changed: 481 additions & 26 deletions

File tree

‎CHANGELOG.md‎

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

7272
### Fixed
7373

74+
- **`subscriptions.list()` and `subscriptions.events()` no longer report a
75+
malformed success as "nothing there" (#142), sync and async.** A 200 without
76+
a `data.subscriptions` list returned `[]`, and one without `data.events` /
77+
`data.cursor` returned an empty page with `cursor=None`. Fed back as
78+
`events(since=page.cursor)`, that `None` dropped `since`, and the API reads a
79+
missing `since` as `0`, so the poller replayed the account's whole event
80+
history. Both now raise `OilPriceAPIError(code="MALFORMED_RESPONSE")` with the
81+
raw body when the collection is missing or mistyped, a record is invalid,
82+
`cursor` is not a non-negative integer, `has_more` is not a boolean, or the
83+
cursor is behind `since` or behind an event in the page. A genuinely empty
84+
list or page is still an empty success, and `page.cursor` is now always an
85+
`int`.
86+
- **`subscriptions.events(since=...)` refuses a cursor the API would read as
87+
`0`.** The API parses `since` with `to_i`, so `"abc"`, `""` and `-1` replay
88+
every event and `1.5` becomes `1` (verified against production on
89+
2026-09-13). Anything but a non-negative `int` or `None` now raises
90+
`ValidationError(field="since", status_code=None)` before a request is sent.
7491
- **`subscriptions.create()` no longer turns a malformed success into a
7592
half-built record.** It fell back to treating the whole `data` object as the
7693
subscription when `data.subscription` was missing, and leaked a raw pydantic

‎oilpriceapi/_subscriptions_common.py‎

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414

1515
if TYPE_CHECKING:
1616
from .models import Subscription
17+
from .resources.subscriptions import SubscriptionEventsPage
1718

1819
# Default attribution source stamped on subscriptions created via this SDK.
1920
DEFAULT_SOURCE = "sdk-python"
@@ -251,6 +252,152 @@ def unwrap_subscription(response: Any, *, subject: str) -> "Subscription":
251252
) from error
252253

253254

255+
def _field_errors(error: Any) -> str:
256+
return ", ".join(".".join(str(part) for part in item["loc"]) for item in error.errors())
257+
258+
259+
def unwrap_subscription_list(response: Any, *, subject: str) -> List["Subscription"]:
260+
"""Return the typed ``data.subscriptions`` list from a success body.
261+
262+
``GET /v1/subscriptions`` always answers
263+
``{"status": "success", "data": {"subscriptions": [...]}}``. An empty list is
264+
returned only when the API sent an empty list.
265+
266+
Raises:
267+
OilPriceAPIError: ``code="MALFORMED_RESPONSE"`` when ``data.subscriptions``
268+
is missing or not a list, or a record in it is not a valid
269+
subscription. A malformed success is never reported as "no
270+
subscriptions".
271+
"""
272+
from pydantic import ValidationError as PydanticValidationError
273+
274+
from ._fuel_surcharge_common import _malformed
275+
from .models import Subscription
276+
277+
data = response.get("data") if isinstance(response, dict) else None
278+
records = data.get("subscriptions") if isinstance(data, dict) else None
279+
if not isinstance(records, list):
280+
raise _malformed(subject, "expected data.subscriptions to be a list", response)
281+
282+
subscriptions: List[Subscription] = []
283+
for index, record in enumerate(records):
284+
if not isinstance(record, dict):
285+
raise _malformed(
286+
subject, f"expected data.subscriptions[{index}] to be an object", response
287+
)
288+
try:
289+
subscriptions.append(Subscription(**record))
290+
except PydanticValidationError as error:
291+
raise _malformed(
292+
subject,
293+
f"data.subscriptions[{index}] has {error.error_count()} invalid or missing "
294+
f"field(s): {_field_errors(error)}",
295+
response,
296+
) from error
297+
return subscriptions
298+
299+
300+
def validate_since(since: Any) -> Optional[int]:
301+
"""Return ``since`` if the API will read it as the cursor it is.
302+
303+
``GET /v1/subscriptions/events`` reads ``params[:since].to_i``: a blank,
304+
non-numeric or negative value becomes ``0`` and replays every event the
305+
account has, and ``1.5`` becomes ``1``. ``None`` (omitted) is the first
306+
poll; anything else must be a previous page's integer ``cursor``, or ``0``.
307+
308+
Raises:
309+
ValidationError: ``field="since"``, ``status_code=None``. Nothing is sent.
310+
"""
311+
if since is None:
312+
return None
313+
if isinstance(since, bool) or not isinstance(since, int) or since < 0:
314+
raise _refuse(
315+
f"Invalid events cursor since={since!r}: pass page.cursor from the previous "
316+
f"events() call, 0 to start from the first event, or omit it on the first "
317+
f"poll. The API reads any other value as 0 and replays every event.",
318+
"since",
319+
since,
320+
)
321+
return since
322+
323+
324+
def unwrap_events_page(
325+
response: Any, *, since: Optional[int], subject: str
326+
) -> "SubscriptionEventsPage":
327+
"""Return the typed events page from a ``GET /v1/subscriptions/events`` body.
328+
329+
The API always sends ``data.cursor`` (an integer: the last event's ``seq``,
330+
or ``since`` when there are none), ``data.has_more`` and ``data.events``.
331+
The cursor is what the caller sends as ``since`` next, so a missing or
332+
wrong-typed cursor is refused rather than defaulted: ``cursor=None`` would
333+
make the next poll omit ``since`` and restart from the first event.
334+
335+
Raises:
336+
OilPriceAPIError: ``code="MALFORMED_RESPONSE"`` when ``data.events`` is
337+
not a list of events, ``data.cursor`` is not a non-negative integer,
338+
``data.has_more`` is not a boolean, or the cursor is behind ``since``
339+
or behind an event in the page (following it would replay events).
340+
"""
341+
from pydantic import ValidationError as PydanticValidationError
342+
343+
from ._fuel_surcharge_common import _malformed
344+
from .models import SubscriptionEvent
345+
from .resources.subscriptions import SubscriptionEventsPage
346+
347+
data = response.get("data") if isinstance(response, dict) else None
348+
if not isinstance(data, dict):
349+
raise _malformed(subject, "expected a 'data' object", response)
350+
351+
records = data.get("events")
352+
if not isinstance(records, list):
353+
raise _malformed(subject, "expected data.events to be a list", response)
354+
355+
cursor = data.get("cursor")
356+
if isinstance(cursor, bool) or not isinstance(cursor, int) or cursor < 0:
357+
raise _malformed(
358+
subject,
359+
f"expected data.cursor to be a non-negative integer, got {cursor!r}",
360+
response,
361+
)
362+
363+
has_more = data.get("has_more")
364+
if not isinstance(has_more, bool):
365+
raise _malformed(
366+
subject, f"expected data.has_more to be true or false, got {has_more!r}", response
367+
)
368+
369+
if since is not None and cursor < since:
370+
raise _malformed(
371+
subject,
372+
f"data.cursor {cursor} is behind since={since}; following it would replay events",
373+
response,
374+
)
375+
376+
events: List[SubscriptionEvent] = []
377+
for index, record in enumerate(records):
378+
if not isinstance(record, dict):
379+
raise _malformed(subject, f"expected data.events[{index}] to be an object", response)
380+
try:
381+
event = SubscriptionEvent(**record)
382+
except PydanticValidationError as error:
383+
raise _malformed(
384+
subject,
385+
f"data.events[{index}] has {error.error_count()} invalid field(s): "
386+
f"{_field_errors(error)}",
387+
response,
388+
) from error
389+
if event.seq is not None and event.seq > cursor:
390+
raise _malformed(
391+
subject,
392+
f"data.cursor {cursor} is behind data.events[{index}].seq {event.seq}; "
393+
f"following it would replay events",
394+
response,
395+
)
396+
events.append(event)
397+
398+
return SubscriptionEventsPage(events=events, cursor=cursor, has_more=has_more)
399+
400+
254401
def unwrap_data(response: Any) -> Dict[str, Any]:
255402
"""Return the ``data`` object from a ``{status, data}`` envelope."""
256403
if isinstance(response, dict) and "data" in response:

‎oilpriceapi/async_resources.py‎

Lines changed: 15 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,10 @@
88
build_attribution_headers,
99
build_create_body,
1010
build_update_body,
11-
unwrap_data,
11+
unwrap_events_page,
1212
unwrap_subscription,
13+
unwrap_subscription_list,
14+
validate_since,
1315
validate_subscription_id,
1416
)
1517
from .exceptions import ValidationError
@@ -42,7 +44,6 @@
4244
ParcelFuelSurchargeCarrier,
4345
PriceAlert,
4446
Subscription,
45-
SubscriptionEvent,
4647
)
4748
from .resource_validators import (
4849
VALID_OPERATORS,
@@ -1600,11 +1601,13 @@ def __init__(self, client: Any) -> None:
16001601
self.client = client
16011602

16021603
async def list(self) -> List[Subscription]:
1603-
"""List all subscriptions for the authenticated user."""
1604+
"""List all subscriptions. See ``SubscriptionsResource.list``.
1605+
1606+
Raises ``OilPriceAPIError(code="MALFORMED_RESPONSE")`` when a success
1607+
body has no ``data.subscriptions`` list; empty only when the API sent [].
1608+
"""
16041609
response = await self.client.request(method="GET", path="/v1/subscriptions")
1605-
data = unwrap_data(response)
1606-
subs = data.get("subscriptions", [])
1607-
return [Subscription(**s) for s in subs]
1610+
return unwrap_subscription_list(response, subject="subscriptions.list")
16081611

16091612
async def create(
16101613
self,
@@ -1706,8 +1709,12 @@ async def events(
17061709
) -> SubscriptionEventsPage:
17071710
"""Poll for subscription events newer than a cursor.
17081711
1709-
Returns a SubscriptionEventsPage with events, cursor, and has_more.
1712+
See ``SubscriptionsResource.events``. ``since`` must be a non-negative
1713+
int (``ValidationError``, nothing sent, otherwise), and a success body
1714+
without an integer cursor raises ``MALFORMED_RESPONSE`` rather than
1715+
returning ``cursor=None``, which would restart polling from event 1.
17101716
"""
1717+
since = validate_since(since)
17111718
params: Dict[str, Any] = {}
17121719
if since is not None:
17131720
params["since"] = since
@@ -1721,11 +1728,7 @@ async def events(
17211728
path="/v1/subscriptions/events",
17221729
params=params,
17231730
)
1724-
data = unwrap_data(response)
1725-
events = [SubscriptionEvent(**e) for e in data.get("events", [])]
1726-
cursor = data.get("cursor")
1727-
has_more = bool(data.get("has_more", False))
1728-
return SubscriptionEventsPage(events=events, cursor=cursor, has_more=has_more)
1731+
return unwrap_events_page(response, since=since, subject="subscriptions.events")
17291732

17301733

17311734
class AsyncFuelSurchargeResource:

‎oilpriceapi/resources/subscriptions.py‎

Lines changed: 28 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,16 @@
55
periodically evaluate commodity codes and emit events an agent can poll for.
66
"""
77

8-
from typing import Any, Dict, List, Optional, Union, cast
8+
from typing import Any, Dict, List, Optional, Union
99

1010
from .._subscriptions_common import (
1111
build_attribution_headers,
1212
build_create_body,
1313
build_update_body,
14-
unwrap_data,
14+
unwrap_events_page,
1515
unwrap_subscription,
16+
unwrap_subscription_list,
17+
validate_since,
1618
validate_subscription_id,
1719
)
1820
from ..models import Subscription, SubscriptionEvent
@@ -57,16 +59,19 @@ def list(self) -> List[Subscription]:
5759
"""List all subscriptions for the authenticated user.
5860
5961
Returns:
60-
List of Subscription models.
62+
List of Subscription models. Empty only when the API sent an empty
63+
list.
64+
65+
Raises:
66+
OilPriceAPIError: ``code="MALFORMED_RESPONSE"`` when a success body
67+
has no ``data.subscriptions`` list or a record in it is invalid.
6168
6269
Example:
6370
>>> for sub in client.subscriptions.list():
6471
... print(sub.name, sub.codes)
6572
"""
6673
response = self.client.request(method="GET", path="/v1/subscriptions")
67-
data = unwrap_data(response)
68-
subs = data.get("subscriptions", [])
69-
return [Subscription(**s) for s in subs]
74+
return unwrap_subscription_list(response, subject="subscriptions.list")
7075

7176
def create(
7277
self,
@@ -254,19 +259,32 @@ def events(
254259
"""Poll for subscription events newer than a cursor.
255260
256261
Args:
257-
since: Sequence cursor; only events with seq > since are returned.
262+
since: ``page.cursor`` from the previous call; only events with
263+
``seq > since`` are returned. Omit it (or pass ``0``) only to
264+
start from the first event. Must be a non-negative ``int``: the
265+
API reads any other value as ``0`` and replays every event.
258266
limit: Max events to return (server clamps to its own max).
259267
watch_id: Restrict to a single subscription.
260268
261269
Returns:
262-
A SubscriptionEventsPage with events, cursor, and has_more.
270+
A SubscriptionEventsPage with events, cursor, and has_more. The
271+
cursor is always an ``int``, so ``events(since=page.cursor)`` never
272+
restarts from the beginning.
273+
274+
Raises:
275+
ValidationError: ``field="since"``, ``status_code=None``, if ``since``
276+
is not a non-negative int. Nothing is sent.
277+
OilPriceAPIError: ``code="MALFORMED_RESPONSE"`` when a success body
278+
lacks the ``events`` list, an integer ``cursor`` or a boolean
279+
``has_more``, or its cursor would move polling backwards.
263280
264281
Example:
265282
>>> page = client.subscriptions.events(since=0)
266283
>>> for event in page:
267-
... print(event.type, event.code)
284+
... print(event.seq, event.watch_id)
268285
>>> next_page = client.subscriptions.events(since=page.cursor)
269286
"""
287+
since = validate_since(since)
270288
params: Dict[str, Any] = {}
271289
if since is not None:
272290
params["since"] = since
@@ -280,8 +298,4 @@ def events(
280298
path="/v1/subscriptions/events",
281299
params=params,
282300
)
283-
data = unwrap_data(response)
284-
events = [SubscriptionEvent(**e) for e in data.get("events", [])]
285-
cursor = cast(Optional[int], data.get("cursor"))
286-
has_more = bool(data.get("has_more", False))
287-
return SubscriptionEventsPage(events=events, cursor=cursor, has_more=has_more)
301+
return unwrap_events_page(response, since=since, subject="subscriptions.events")

0 commit comments

Comments
 (0)