"""Shared fail-loud protection for cursor-based pagination walks.""" from __future__ import annotations from dataclasses import dataclass, field from typing import NoReturn class PaginationGuardError(RuntimeError): """A remote paginator did not make safe, bounded progress.""" @dataclass class PaginationGuard: """Track page count, item count, and every cursor already observed.""" integration: str action: str max_pages: int max_items: int | None = None pages: int = 0 item_count: int = 0 _seen_cursors: set[str] = field(default_factory=set) def record_page(self, item_count: int) -> None: self.pages += 1 self.item_count += item_count if self.max_items is not None and self.item_count > self.max_items: self._raise("max_items_exceeded") def require_safe_next( self, cursor: object | None, *, required: bool = False, ) -> bool: """Return false on termination; raise on malformed or unsafe progress. ``required`` is for protocols such as Salesforce query pagination where an explicit ``done=false`` promises a continuation cursor. A missing cursor in that state is a truncated snapshot, not successful termination. """ if cursor is None or cursor == "": if required: self._raise("missing_next_cursor") return False if self.pages >= self.max_pages: self._raise("max_pages_exceeded") cursor_key = str(cursor) if cursor_key in self._seen_cursors: self._raise("cursor_cycle_detected") self._seen_cursors.add(cursor_key) return True def reject_malformed_response(self) -> NoReturn: """Fail without embedding any provider-controlled response data.""" self._raise("malformed_response") def _raise(self, reason: str) -> NoReturn: # Cursor values are intentionally excluded: opaque cursors can carry # bearer-like values or sensitive query parameters. raise PaginationGuardError( "pagination failed loud: " f"integration={self.integration} action={self.action} " f"reason={reason} pages={self.pages} item_count={self.item_count}" )