Skip to main content

Caching, retries, and rate limits

The upstream Guides recommend how both parties fetch public metadata, retry a refused request, and share a company's request budget. Both SDKs implement those recommendations as Reactor contributions with safe defaults, so a host replaces any of them (a Redis cache, a distributed budget) without changing a client.

BehaviourReactor pointDefault
Bounded metadata cachepap.cacheIn-memory, least recently used, 1024 entries
Retry policypap.retryPolicy3 attempts, 0.5 s base, 30 s cap, Retry-After up to 10 s
Shared rate-limit budgetpap.rateLimitBudgetPer company origin, in one process; pauses shared, no rate cap

Fetching metadata​

Discovery documents (poppy.json), OAuth authorization-server metadata, client metadata, and JWKS are public, and each fetch is bounded:

  • Addresses. The URL safety policy resolves the host and approves its addresses; in Python the default transport then dials only those (PinnedNetworkBackend), never resolving the host again, so DNS cannot rebind it between the check and the connection. TLS still verifies the host name. In TypeScript on Node, createResolvingFetch (./server, the Node company's default) does the same: it vets every resolved address and dials the vetted one.
  • Size and time. At most 100 KB (max_response_bytes / maxResponseBytes) and 5 seconds in all (timeout / timeoutMs), however slowly the server sends: a body trickled a few bytes at a time is abandoned with metadata_timeout.
  • No credentials. No cookie, Authorization, or DPoP header is sent.

Everything else sent to a company​

A company's metadata names its other endpoints — token, sign-in, conversations, API, Operations, MCP — and a company can point those names anywhere, its own network included. A host that acts for many people (a gateway inside a cluster) sends them through create_resolving_client in Python, the same boundary as createResolvingFetch on Node: before every request, and every redirect hop the SDK follows, the host is resolved and the request refused unless it is HTTPS and every address is public (DiscoveryError: unsafe_url, unsafe_address, dns_failed); nothing is sent and nothing is tried elsewhere. The connection then dials only the approved addresses (ResolvingTransport), TLS still naming the host. It follows no redirect itself and uses no proxy from the environment; max_response_bytes stops reading any body past it (response_too_large) for code that reads whole bodies on its own. Keep the host's own calls (its identity provider, its stores) on another client.

Caching by Cache-Control, within bounds​

A fetched document is kept as long as its Cache-Control says, clamped to at least a minute and at most a day (CacheBounds / cacheBounds):

Cache-ControlKept for
absent, or without max-agea minute (default_seconds)
max-age=3600an hour
max-age=10a minute (the lower bound)
max-age=172800a day (the upper bound)
no-store, no-cache, max-age=0, a malformed max-agenot cached

Entries expire by the Reactor pap.clock, so a test or a demo that moves its clock sees them expire. A failed fetch is never cached.

from personal_agent_protocol import DiscoveryClient
from personal_agent_protocol.caching import CacheBounds

# Keep every document at least five minutes (the default lifetime must lie within).
bounds = CacheBounds(min_seconds=300, default_seconds=300)
client = DiscoveryClient(reactor=reactor, cache_bounds=bounds)
company = await client.discover("shop.example") # fetched
company = await client.discover("shop.example") # from pap.cache
import { discoverCompany } from '@datalayer/personal-agent-protocol';

// One reactor, so one pap.cache, across calls.
const company = await discoverCompany('shop.example', { reactor });

Overlapping JWKS​

An agent rotating its signing key publishes the new key beside the old one, then signs with it. A company that cached the old set sees an unknown kid: IdentityClientResolver (both SDKs) passes the assertion's kid to fetch_jwks(client_id, kid=...) / fetchJwks(clientId, { kid }), which fetches the set again, bypassing the cache. It does so at most once a minute for each jwks_uri (jwks_refetch_interval / jwksRefetchIntervalSeconds), so a stream of assertions naming made-up keys cannot make the company fetch on every request. The kid is used only to look a key up; the signature is still verified against the published key.

During the overlap, assertions signed with either key verify. Once the agent retires the old key (publishes the set without it), the company keeps accepting it only until its cached set expires — the set's Cache-Control, within the bounds above — and refuses it after that with invalid_client. An agent therefore keeps the old key published, and stops signing with it, for at least its JWKS max-age before removing it. A real rotation that arrives within a minute of a refetch caused by a made-up kid waits for the next refetch. test_guide_security.py and guide-security.test.mjs play the rotation against each reference company.

Retries: Retry-After, backoff, and jitter​

DpopHttpClient.request / DpopFetchClient.request handle a 429 or 503:

  1. A Retry-After (seconds or an HTTP date) is always honoured: a request is never retried sooner. One longer than max_retry_after (10 s by default) is returned to the caller, who sees rate_limited with retry_after.
  2. Without Retry-After, the delay backs off exponentially from base_delay, capped at max_delay, with jitter (at least half the backoff, so a retry is never immediate).
  3. At most max_attempts tries in all.
  4. Only safe methods (GET, HEAD, OPTIONS) are retried on their own. A write is returned to the caller, which retries it with its idempotency key or message ID unchanged.
  5. Every attempt has a fresh DPoP proof.

Shared rate-limit budgets​

The Guides ask personal agents to keep track of each company's limits and to share them across tasks and users. Every request draws on pap.rateLimitBudget for its company's origin, and a Retry-After is recorded there with defer: every other task for that company then waits it out too, instead of being refused in turn. A wait longer than max_wait is refused locally with rate_limited.

SharedRateLimitBudget(rate=..., burst=...) also paces requests per company (the generic cell rate algorithm): each caller reserves its turn when it asks, counting every caller ahead of it and the company's pause, so concurrent callers never exceed the rate, and a caller whose turn is more than max_wait away is refused at once, however long the queue. Load tests check it under concurrent callers. A multi-process host contributes a budget backed by shared storage:

from reactor import PluginManifest

from personal_agent_protocol import RATE_LIMIT_BUDGETS, build_pap_reactor


class RedisBudget:
async def acquire(self, key: str) -> None: ...
async def defer(self, key: str, seconds: float) -> None: ...


class BudgetPlugin:
def provide_contributions(self, contributions):
contributions.contribute(RATE_LIMIT_BUDGETS, RedisBudget(), contribution_id="redis", order=10)


reactor, _ = build_pap_reactor(plugins=[(PluginManifest(name="budget", version="1"), BudgetPlugin())])

The tests are test_guidance.py (Python) and guidance.test.mjs (TypeScript); the conformance fixtures security.slow-metadata and security.dns-rebinding-after-check cover the fetch bounds.