Skip to content

Commit 142c672

Browse files
committed
feat: auto-paginate get_events, handle WebSocket join rejection
- get_events() now loops while has_more=true, auto-paginating - Gateway raises TooManyMissedEventsError on join rejection (no auto-retry) - Regenerated models with has_more field in EventsResponse
1 parent 853a093 commit 142c672

5 files changed

Lines changed: 54 additions & 12 deletions

File tree

stackcoin/stackcoin/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
"""StackCoin Python library."""
22

33
from .client import AnyEvent, Client
4-
from .errors import StackCoinError
4+
from .errors import StackCoinError, TooManyMissedEventsError
55
from .gateway import Gateway
66
from .models import (
77
Event,
@@ -27,6 +27,7 @@
2727
"RequestDeniedData",
2828
"RequestDeniedEvent",
2929
"StackCoinError",
30+
"TooManyMissedEventsError",
3031
"TransferCompletedData",
3132
"TransferCompletedEvent",
3233
]

stackcoin/stackcoin/client.py

Lines changed: 22 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -193,14 +193,28 @@ async def get_transaction(self, transaction_id: int) -> Transaction:
193193
return Transaction.model_validate(resp.json())
194194

195195
async def get_events(self, *, since_id: int = 0) -> list[AnyEvent]:
196-
"""Return typed events since the given ID."""
197-
params: dict[str, Any] = {}
198-
if since_id:
199-
params["since_id"] = since_id
200-
resp = await self._http.get("/api/events", params=params)
201-
self._raise_for_error(resp)
202-
wrapper = EventsResponse.model_validate(resp.json())
203-
return [e.root for e in wrapper.events]
196+
"""Return typed events since the given ID.
197+
198+
Automatically paginates through all available events.
199+
"""
200+
all_events: list[AnyEvent] = []
201+
cursor = since_id
202+
203+
while True:
204+
params: dict[str, Any] = {}
205+
if cursor:
206+
params["since_id"] = cursor
207+
resp = await self._http.get("/api/events", params=params)
208+
self._raise_for_error(resp)
209+
wrapper = EventsResponse.model_validate(resp.json())
210+
page = [e.root for e in wrapper.events]
211+
all_events.extend(page)
212+
213+
if not wrapper.has_more or not page:
214+
break
215+
cursor = page[-1].id
216+
217+
return all_events
204218

205219
async def get_discord_guilds(self) -> list[DiscordGuild]:
206220
"""Return all Discord guilds."""

stackcoin/stackcoin/errors.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,3 +6,12 @@ def __init__(self, status_code: int, error: str, message: str | None = None):
66
self.error = error
77
self.message = message
88
super().__init__(f"{status_code} {error}: {message}")
9+
10+
11+
class TooManyMissedEventsError(StackCoinError):
12+
"""Raised when the WebSocket gateway rejects a join due to too many missed events."""
13+
14+
def __init__(self, missed_count: int, replay_limit: int, message: str):
15+
super().__init__(status_code=0, error="too_many_missed_events", message=message)
16+
self.missed_count = missed_count
17+
self.replay_limit = replay_limit

stackcoin/stackcoin/gateway.py

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,8 @@ async def connect(self) -> None:
6565
"""Connect and listen for events. Reconnects automatically on failure."""
6666
import websockets
6767

68+
from .errors import TooManyMissedEventsError
69+
6870
self._running = True
6971

7072
while self._running:
@@ -83,12 +85,16 @@ async def connect(self) -> None:
8385
finally:
8486
heartbeat_task.cancel()
8587

88+
except TooManyMissedEventsError:
89+
raise # Don't retry — caller must catch up via REST
8690
except Exception:
8791
if self._running:
8892
await asyncio.sleep(5)
8993

9094
async def _join_channel(self, ws: Any) -> None:
9195
"""Join the user:self channel with event replay."""
96+
from .errors import TooManyMissedEventsError
97+
9298
self._ref_counter += 1
9399
join_msg = json.dumps(
94100
[
@@ -102,8 +108,19 @@ async def _join_channel(self, ws: Any) -> None:
102108
await ws.send(join_msg)
103109

104110
reply = json.loads(await asyncio.wait_for(ws.recv(), timeout=10))
105-
if not (reply[3] == "phx_reply" and reply[4].get("status") == "ok"):
106-
raise ConnectionError(f"Failed to join channel: {reply}")
111+
if reply[3] == "phx_reply" and reply[4].get("status") == "ok":
112+
return
113+
114+
# Check for too_many_missed_events rejection
115+
response = reply[4].get("response", {})
116+
if response.get("reason") == "too_many_missed_events":
117+
raise TooManyMissedEventsError(
118+
missed_count=response.get("missed_count", 0),
119+
replay_limit=response.get("replay_limit", 0),
120+
message=response.get("message", "Too many missed events"),
121+
)
122+
123+
raise ConnectionError(f"Failed to join channel: {reply}")
107124

108125
async def _heartbeat(self, ws: Any) -> None:
109126
"""Send periodic heartbeats."""

stackcoin/stackcoin/models.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# generated by datamodel-codegen:
22
# filename: openapi.json
3-
# timestamp: 2026-03-05T04:32:37+00:00
3+
# timestamp: 2026-03-06T18:41:27+00:00
44

55
from __future__ import annotations
66

@@ -269,3 +269,4 @@ class Event(
269269

270270
class EventsResponse(BaseModel):
271271
events: list[Event] = Field(..., description="The events list")
272+
has_more: bool = Field(..., description="Whether more events exist beyond this page")

0 commit comments

Comments
 (0)