Module adcp.decisioning.pagination
Framework-managed cursor pagination for list responses.
When an adopter sets auto_paginate=True on :class:DecisioningCapabilities,
the handler intercepts get_products, calls the adopter's implementation to
retrieve the full product set, and slices the result to the requested page.
Warning: Adoption ceiling.
auto_paginate=True requires the adopter's
get_products to return the complete unfiltered product set on every call —
the framework slices it. This pattern works well for in-memory and small-catalog
sellers (≲10 k products). Adopters with DB-backed catalogs at production scale
MUST handle cursor logic natively and leave auto_paginate=False (the default).
Returning a 100 k-product list only to have the framework discard 99 950 rows is
a silent production latency and memory spike that only manifests at scale.
The cursor is an HMAC-SHA-256-signed JSON envelope::
base64url( JSON({ "p": JSON({"v":1,"o":<offset>,"qh":<filter-hash>}), "s":<hex-sig> }) )
The qh field is a SHA-256 fingerprint of the request parameters (excluding
pagination itself), so if a buyer changes filters between pages the framework
returns INVALID_REQUEST with field="pagination.cursor" rather than silently
serving the wrong page slice.
Secret management. The HMAC key defaults to a per-process random secret (stable
within the process, different across restarts). Stale cursors from a prior process
return INVALID_REQUEST — this is the correct behaviour for stateless sellers.
Horizontally-scaled or stateless sellers that need cursors to survive process restarts
MUST set ADCP_PAGINATION_SECRET in the environment.
Functions
def apply_framework_pagination(response: Any, pagination: Any, query_hash: str, secret: bytes | None = None) ‑> Any-
Expand source code
def apply_framework_pagination( response: Any, pagination: Any, query_hash: str, secret: bytes | None = None, ) -> Any: """Slice a full-list ``GetProductsResponse`` to the requested page. Called by the handler post-adapter when ``auto_paginate=True`` on :class:`~adcp.decisioning.platform.DecisioningCapabilities`. Short-circuits (returns *response* unchanged) when: * ``response.pagination`` is already populated — the adopter handled pagination natively; the framework must not overwrite it. * ``response.products`` is absent — unexpected shape; pass through and let wire validation surface the issue. Clamps ``max_results`` to ``[1, 100]`` before slicing. :param response: The adopter's full-list ``GetProductsResponse``. :param pagination: The wire ``Pagination`` request object (``max_results``, ``cursor``). :param query_hash: Filter fingerprint from :func:`_query_hash` on the original request. Used to anchor the cursor. :param secret: HMAC key override for testing and direct use. ``None`` uses :func:`_secret` (reads ``ADCP_PAGINATION_SECRET`` env var, falls back to the per-process random secret). The handler always passes ``None`` — configure production secrets via the env var. :returns: A new ``GetProductsResponse`` with ``products`` sliced to the page and ``pagination`` populated, or the original *response* if the short-circuit fired. """ # Short-circuit: adopter already populated pagination. Also short-circuits # when _invoke_platform_method returned a TaskHandoff projection dict # ({"task_id": ..., "status": "submitted"}) — dicts have no .products # attribute so the products-None branch below fires and passes through. if getattr(response, "pagination", None) is not None: return response products = getattr(response, "products", None) if products is None: return response # Decode cursor or start at offset 0. cursor_str = getattr(pagination, "cursor", None) if cursor_str: offset = _decode_cursor(cursor_str, query_hash, secret) else: offset = 0 # Clamp max_results to wire schema bounds [1, 100]. raw_max = getattr(pagination, "max_results", None) max_results = max(1, min(100, raw_max if raw_max is not None else 50)) full_list = list(products) page = full_list[offset : offset + max_results] has_more = (offset + max_results) < len(full_list) next_cursor = ( _encode_cursor(offset + max_results, query_hash, secret) if has_more else None ) # Deferred: adcp.types imports adcp.decisioning.types; top-level import is circular. from adcp.types import PaginationResponse new_pagination = PaginationResponse(has_more=has_more, cursor=next_cursor) return response.model_copy(update={"products": page, "pagination": new_pagination})Slice a full-list
GetProductsResponseto the requested page.Called by the handler post-adapter when
auto_paginate=Trueon :class:~adcp.decisioning.platform.DecisioningCapabilities.Short-circuits (returns response unchanged) when:
response.paginationis already populated — the adopter handled pagination natively; the framework must not overwrite it.response.productsis absent — unexpected shape; pass through and let wire validation surface the issue.
Clamps
max_resultsto[1, 100]before slicing.:param response: The adopter's full-list
GetProductsResponse. :param pagination: The wirePaginationrequest object (max_results,cursor). :param query_hash: Filter fingerprint from :func:_query_hashon the original request. Used to anchor the cursor. :param secret: HMAC key override for testing and direct use.Noneuses :func:_secret(readsADCP_PAGINATION_SECRETenv var, falls back to the per-process random secret). The handler always passesNone— configure production secrets via the env var. :returns: A newGetProductsResponsewithproductssliced to the page andpaginationpopulated, or the original response if the short-circuit fired.