Skip to content

[P2][bug] subscriptions.list() and events() turn a malformed 200 into an empty success #142

Closed

Description

@karlwaldman

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#index always renders {subscriptions: [...]} and #events always renders {cursor, has_more, events} (oilpriceapi-api origin/main app/controllers/v1/subscriptions_controller.rb). A body without them is a malformed response (proxy page, envelope change), not an empty account.
  • events() returning cursor=None is the harmful part: the documented loop client.subscriptions.events(since=page.cursor) then omits since, the server reads params[: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

  1. added a commit that references this issue on Sep 13, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions