Repository navigation
[P2][bug] subscriptions.list() and events() turn a malformed 200 into an empty success #142
Description
Summary
SubscriptionsResource.list() / events() (sync and async) unwrap with data.get("subscriptions", []) and data.get("events", []) / data.get("cursor"). Any 200 body that lacks those keys is reported as "you have no subscriptions" / "no new events", and events() returns cursor=None.
Status: confirmed on branch feat/100-subscriptions-lifecycle @ 1feb882, 2026-09-13. list()/events() are unchanged from origin/main 4bd2900 there. PR #100's branch fixes the same defect for create() (it fell back to data itself as the record) and adds id validation to delete(); list() and events() are intentionally left for this issue.
Why it matters
V1::SubscriptionsController#indexalways renders{subscriptions: [...]}and#eventsalways renders{cursor, has_more, events}(oilpriceapi-apiorigin/mainapp/controllers/v1/subscriptions_controller.rb). A body without them is a malformed response (proxy page, envelope change), not an empty account.events()returningcursor=Noneis the harmful part: the documented loopclient.subscriptions.events(since=page.cursor)then omitssince, the server readsparams[:since].to_i == 0, and the poller replays the account's entire event history as new.
Repro (mocked transport, real client)
import json
from unittest.mock import Mock, patch
from oilpriceapi import OilPriceAPI
FIXTURE = "-".join(["fixture", "not", "a", "real", "key"])
def resp(payload):
r = Mock(); r.status_code = 200; r.headers = {}
r.content = json.dumps(payload).encode(); r.text = r.content.decode()
r.json.return_value = payload
return r
c = OilPriceAPI(api_key=FIXTURE, max_retries=1)
for payload in [{"status": "success", "data": {}}, {"status": "success", "data": []}, {"message": "maintenance"}]:
with patch("httpx.Client.request", return_value=resp(payload)):
print(c.subscriptions.list())
for payload in [{"status": "success", "data": {}}, {}]:
with patch("httpx.Client.request", return_value=resp(payload)):
page = c.subscriptions.events(since=41)
print(page, page.cursor)Output:
list, data without subscriptions -> []
list, data is a list -> []
list, unrelated object -> []
events, data without events/cursor -> SubscriptionEventsPage(events=0, cursor=None, has_more=False) cursor: None
events, empty object -> SubscriptionEventsPage(events=0, cursor=None, has_more=False) cursor: None
Expected
Raise OilPriceAPIError(code="MALFORMED_RESPONSE", raw_body=...) when data.subscriptions is not a list of objects, or when data.events is not a list / data.cursor is not an integer. A genuinely empty {"subscriptions": []} / {"events": [], "cursor": 41, "has_more": false} stays an empty success. Reuse unwrap_subscription-style strictness from oilpriceapi/_subscriptions_common.py so sync and async share it.
Files: oilpriceapi/resources/subscriptions.py (list, events), oilpriceapi/async_resources.py (AsyncSubscriptionsResource.list, .events).
Activity
chore(release): 1.16.0 (#152)