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.
| Behaviour | Reactor point | Default |
|---|---|---|
| Bounded metadata cache | pap.cache | In-memory, least recently used, 1024 entries |
| Retry policy | pap.retryPolicy | 3 attempts, 0.5 s base, 30 s cap, Retry-After up to 10 s |
| Shared rate-limit budget | pap.rateLimitBudget | Per 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 withmetadata_timeout. - No credentials. No cookie,
Authorization, orDPoPheader 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-Control | Kept for |
|---|---|
absent, or without max-age | a minute (default_seconds) |
max-age=3600 | an hour |
max-age=10 | a minute (the lower bound) |
max-age=172800 | a day (the upper bound) |
no-store, no-cache, max-age=0, a malformed max-age | not 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:
- A
Retry-After(seconds or an HTTP date) is always honoured: a request is never retried sooner. One longer thanmax_retry_after(10 s by default) is returned to the caller, who seesrate_limitedwithretry_after. - Without
Retry-After, the delay backs off exponentially frombase_delay, capped atmax_delay, with jitter (at least half the backoff, so a retry is never immediate). - At most
max_attemptstries in all. - 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. - 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.