Skip to content

Say when a route needs the container PDP, document which proxied writes wait, send X-Wait-Timeout: 0 - #142

Draft
zeevmoney wants to merge 26 commits into
per-16344/session-reusefrom
per-16340/container-only-and-sync-docs
Draft

zeevmoney wants to merge 26 commits into
per-16344/session-reusefrom
per-16340/container-only-and-sync-docs

Conversation

@zeevmoney

Copy link
Copy Markdown
Member

Linear issues

  • PER-16340: say "container PDP only" when a route only the container PDP serves is called on the cloud PDP.
  • PER-16339: document which facts writes proxied through the PDP wait for sync.
  • PER-16681: send X-Wait-Timeout when the facts sync timeout is 0.

Stacked on #141.

Why

  • The cloud PDP serves the decision routes and /health only. It answers 404 for the /facts routes that proxy_facts_via_pdp sends facts to, for the /local routes of permit.pdp_api, and for /user-tenants. For the first two, the SDK raised a generic 404 API Error: {'details': ''}, which does not say that the route needs the container PDP.
  • wait_for_sync(), facts_sync_timeout and proxy_facts_via_pdp read as if every proxied facts write waits for the PDP to have the change. The PDP waits on 11 routes and forwards every other facts request without waiting.
  • facts_sync_timeout=0 and wait_for_sync(timeout=0) sent no X-Wait-Timeout header, because the SDK checked the value's truthiness. The PDP then waited its default of 10 seconds instead of not waiting.

What changed

Routes only the container PDP serves (PER-16340)

  • permit/utils/cloud_pdp.py decides when a 404 on a container-only route is the cloud PDP's. It is when the pdp URL's host is cloudpdp.api.permit.io (any scheme, port, path or letter case), or when the 404 body is empty (zero bytes). Every other 404 keeps its current error and message: a container PDP's JSON 404, and the API's 404 that a container PDP's /facts routes pass on, such as for a missing tenant (PermitNotFoundError).
  • SimpleHttpClient takes a keyword-only container_pdp_advice. Every facts client under proxy_facts_via_pdp and every permit.pdp_api client sets it. On the cloud PDP's 404, these clients raise a PermitApiError whose message names the route, followed by what to do and the PDP setup docs link. For example: The SDK got status code 404 from the PDP at https://cloudpdp.api.permit.io: only the container PDP serves POST /facts/users, and the cloud PDP does not. PermitApiError takes a keyword-only message.
  • get_user_tenants() keeps the PermitConnectionError and the exact message it got in Add tenants.create_user and get_user_tenants #139. The message is now built by the same helper.
  • Creating a client (async or blocking) with proxy_facts_via_pdp=True and a pdp on the cloud PDP host issues a UserWarning:
    • it is attributed to the line that created the client, for subclasses too;
    • it is issued on each creation, and Python's default filter shows it once per line;
    • clients that wait_for_sync() yields do not issue it;
    • both client docstrings document it in a Warns section.
  • These docstrings now say "Container PDP only": the 47 facts methods that go to the PDP with the proxy on, pdp_api.role_assignments.list() and get_user_tenants(). The sync stub is regenerated.
  • e2e:
    • The container_pdp skip fixture moves to tests/conftest.py. The five e2e tests that need a container PDP use it.
    • The e2e (cloud PDP) job gets two read-only tests that check the new 404 messages and the warning.

Which proxied writes wait for sync (PER-16339)

  • These places list the 11 methods the PDP waits on: the wait_for_sync() docstring, the proxy_facts_via_pdp and facts_sync_timeout field descriptions, and a new README section, "Read-your-writes through the PDP". They also say that every other facts request is forwarded without waiting. The 11 methods are users.create, users.update, users.sync, users.assign_role, users.unassign_role, tenants.create, role_assignments.assign, role_assignments.unassign, resource_instances.create, resource_instances.update and relationship_tuples.create. The README also lists the 17 writes that are forwarded without waiting.
  • tests/facts_methods.py holds a table of all 48 public facts methods, with the exact request each one sends under the proxy. tests/test_facts_sync_offline.py uses it to check that:
    • each method sends X-Wait-Timeout, or not, to the PDP route the table gives;
    • the synced routes equal the /facts operations in the committed PDP spec (.github/api-specs/pdp.json);
    • each of the four documented lists equals the set of methods whose route is synced.
  • API coverage allowlist: 5 PDP "untested" entries that the new tests cover are removed. 19 sdk_only entries (PER-16338) are added for proxied routes that the PDP forwards without listing them in its spec.

Timeout 0 (PER-16681)

  • The SDK now sends X-Wait-Timeout whenever facts_sync_timeout is not None, 0 included. X-Timeout-Policy is unchanged, because none of its values is falsy.

Behaviour changes

  1. Container-only routes on the cloud PDP. On the cloud PDP's 404, facts methods under proxy_facts_via_pdp=True and every permit.pdp_api method still raise PermitApiError, but with a new message that names the route and says it needs the container PDP.
    • err.details changes from {'details': ''} to {'details': '', 'message': <message>}.
    • err.__cause__ is now None; it was aiohttp.ContentTypeError.
    • Other 404s, other statuses and get_user_tenants() are unchanged.
  2. New UserWarning at client creation when proxy_facts_via_pdp=True and pdp is on the cloud PDP host. With warnings turned into errors, creating such a client raises.
  3. A facts sync timeout of 0 now means do not wait. facts_sync_timeout=0 sends X-Wait-Timeout: 0.0, and wait_for_sync(timeout=0) sends X-Wait-Timeout: 0. Before, no header was sent, and the PDP waited its default: 10 seconds, unless PDP_LOCAL_FACTS_WAIT_TIMEOUT sets another.

Additions to the API that existing callers don't need to change for: PermitApiError(..., message=...) and SimpleHttpClient(..., container_pdp_advice=...).

How it was tested

  • Offline suite: 1166 passed, 3 skipped, 0 warnings on both the pydantic-v2 and pydantic-v1 lanes. The base has 787 passed, 3 skipped. The new tests are in tests/test_container_pdp_only_offline.py (251) and tests/test_facts_sync_offline.py (128).

  • How the wire tests work: each one runs on the async and the blocking client against a local pytest-httpserver. It asserts the exact method, path, query, headers and body, and that the other server got nothing. Clients are closed after each call.

  • Mutation checks: 26 mutants of the code these tests guard were run at the final head, covering:

    • the 404 rule and the host check;
    • the advice wiring and the error details;
    • the warning's condition, category and line;
    • the timeout check and wait_for_sync;
    • a docstring note, the README list and an SDK route;
    • the e2e skip.

    25 were caught. The one survivor is equivalent to the original: it starts the frame walk one frame further out, which the __init__ skip makes identical.

  • e2e: 44 tests collect. They need keys and run in CI. With a dummy key and unreachable services, the five container-only e2e tests skip with their reason.

  • Other checks:

    • mypy passes on both lanes and the typing-surface tests pass on both (4 passed);
    • all pre-commit hooks pass;
    • the CI script tests pass (223);
    • the API coverage report exits 0 (PDP GA: 17 covered, 15 excluded, 1 deferred, 0 untested);
    • actionlint and zizmor report nothing.

Owner actions before merge

  • Run CI, including e2e and e2e (cloud PDP). The cloud PDP job now runs 8 tests, two of them new. If one of the new ones fails, the cloud PDP answered GET /local/role_assignments or GET /facts/users/<key> with something other than a 404.

🤖 Generated with Claude Code

zeevmoney and others added 21 commits October 2, 2026 05:13
facts_sync_timeout=0 and wait_for_sync(timeout=0) sent no X-Wait-Timeout
header, because the header was set only for a truthy timeout, so the PDP
waited its own default instead of answering without waiting. The header
is now sent whenever the timeout is not None (PER-16681).

X-Timeout-Policy keeps its check: none of its values is falsy.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
get_user_tenants() now builds its 404 message with a helper that the
other container-only routes can share. The text is unchanged; a new
test pins it exactly. SETUP_PDP_DOCS_LINK moves next to the helper and
stays importable from permit.enforcement.enforcer.

Refs PER-16340.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The cloud PDP answers 404, with an empty body, for the /facts routes
that proxy_facts_via_pdp sends facts to and for the /local routes of
permit.pdp_api. The SDK raised that as a bare "404 API Error". It now
raises a PermitApiError, the type it raised before, whose message names
the route and says only the container PDP serves it; the error's
details hold the message too.

A 404 counts as the cloud PDP's when the pdp setting is the cloud PDP's
host, or when its body is empty. A container PDP's own 404s have a JSON
body, and so do the API's 404s its /facts routes pass on, so a missing
tenant through a container PDP still raises PermitNotFoundError with
its usual message.

PermitApiError takes an optional keyword-only message for this.

The new tests send PATCH /facts/users/{user_id}, so its "untested"
API coverage allowlist entry goes.

Refs PER-16340.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every facts method that proxy_facts_via_pdp sends to the PDP now says
it is container PDP only with the proxy on, and that the cloud PDP's
404 is raised as a PermitApiError that says so. pdp_api's
role_assignments.list() and get_user_tenants() say it plainly, and the
pdp_api entry points say the cloud PDP serves none of its routes.

A test pins the list of facts methods against the public methods of
the facts APIs, and checks each docstring on both clients.

Refs PER-16340.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
With proxy_facts_via_pdp on, each method of the users, tenants, role
assignments, resource instances and relationship tuples APIs now has an
offline wire test that pins the route it sends its request to and that
the request carries X-Wait-Timeout and X-Timeout-Policy (PER-16339).

The test holds the set of facts routes the PDP waits on before it
answers. Those are the only /facts operations the PDP's spec lists, and
a test keeps the set equal to the committed copy of that spec.

The API coverage allowlist drops the five PDP operations these tests now
cover, and lists the other proxied routes the PDP forwards without
listing them in its spec (PER-16338).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The PDP waits until it has a facts write before it answers on 11 routes
only, and forwards every other facts request without waiting. The
docstring of wait_for_sync(), the descriptions of proxy_facts_via_pdp
and facts_sync_timeout and a new README section, "Read-your-writes
through the PDP", now list the methods it waits on. The README also
lists the writes it does not wait on, and says what a timeout of 0 or
None and each timeout policy do (PER-16339).

A test reads the four lists and checks that each names exactly the
methods whose route the PDP waits on, as the offline wire tests pin
them, so the docs cannot drift from the code.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A client created with proxy_facts_via_pdp on and the cloud PDP's host
as its pdp sends every facts request to /facts routes the cloud PDP
does not serve. Its creation now issues a UserWarning that says so,
attributed to the line that created the client, on the async and the
blocking client alike, and past a subclass's super().__init__().

It is issued at each creation; Python's default filter shows it once
per line. A client that wait_for_sync() yields does not issue it.

Refs PER-16340.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The container_pdp fixture, which skipped the get_user_tenants e2e
tests when the PDP in use is the cloud PDP, moves to tests/conftest.py
with a reason that covers every container-only route. The e2e tests
that call permit.pdp_api or write facts through proxy_facts_via_pdp use
it too, so a run with CLOUD_PDP=true, or PDP_URL set to the cloud PDP,
reports them as skipped instead of failing on the cloud PDP's 404.

Refs PER-16340.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Two tests join the get_user_tenants one in the e2e (cloud PDP) job:
permit.pdp_api.role_assignments.list() and, with proxy_facts_via_pdp
on, users.get() raise a PermitApiError that names the route and says
only the container PDP serves it. The second also checks the warning
at the client's creation. Both only read, so they write nothing even
if the cloud PDP ever serves those routes.

Refs PER-16340.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The fixture maps the cloud PDP's host to the local test server through
socket.getaddrinfo, which aiohttp uses unless aiodns is installed. It
now asserts that, instead of letting a test reach the real host.

Refs PER-16340.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Raise a container-PDP message when a facts route under
proxy_facts_via_pdp or a pdp_api route gets the cloud PDP's 404, say
"container PDP only" in the affected docstrings, warn at client
creation when the facts proxy points at the cloud PDP, and skip the
container-only e2e tests on the cloud PDP job (PER-16340).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Send X-Wait-Timeout whenever the facts sync timeout is not None, so 0
means do not wait (PER-16681), and document which proxied facts writes
the PDP waits on, with a test that pins every facts method's route and
sync headers and keeps the docs in step (PER-16339).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The table of every public facts method and the one request it sends
with proxy_facts_via_pdp on moves from test_facts_sync_offline.py to
tests/facts_methods.py, and the container-PDP-only tests use it too:

- the cloud PDP's 404 is now checked for the 47 methods whose request
  goes to the PDP, not 14, so users.get_assigned_roles' own client and
  each route of every facts client are covered;
- the docstring pins take the methods that say "container PDP only",
  and those that must not, from the same table, in place of a list
  typed by hand and a second check of the facts APIs' methods;
- Case gives a request's HTTP method and its sent() form, which both
  modules used to build inline.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The README's read-your-writes section and the proxy_facts_via_pdp
description said only that the cloud PDP answers 404. They now say
that the SDK raises that 404 as a PermitApiError naming the route, and
that a client created with the facts proxy on and the cloud PDP as its
pdp issues a UserWarning.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
CONTRIBUTING.md's API coverage section now says that
tests/facts_methods.py pins the request of every facts method with
proxy_facts_via_pdp on, that the wait and cloud-PDP 404 tests run on
each, and that a new facts method needs a case there.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The cloud PDP answers a route it does not serve with a 404 of zero
bytes. A 404 whose body is only whitespace now keeps the error it
raised before, as the docstring already said, and a test pins it on a
facts route and a pdp_api route (PER-16340).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The cloud PDP serves none of the routes only the container PDP serves,
so every 404 it sends for one is a missing route. Say so where the rule
is defined, so that a cloud PDP that starts to serve one of them gets
its real "not found" told apart there (PER-16340).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The warning at creation fires for any address on the cloud PDP's host,
whatever its scheme, port, path or letter case, and not for an address
on another host that only mentions it, one with no host, or one that
cannot be parsed (PER-16340).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The error for the cloud PDP's 404 on a route only the container PDP
serves keeps the body's text under "details" and adds the message under
"message", whatever the body. Say so in the body argument's docs
(PER-16340).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The warning at creation said what to do in other words than the 404
error does. It now ends with the same advice as that error, and with
the link to the PDP setup docs (PER-16340).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
creation_site() read its caller's frame with sys._getframe(1), which
needed a lint suppression. inspect.currentframe() and f_back reach the
same frame through the public API (PER-16340).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@linear-code

linear-code Bot commented Oct 2, 2026 •

Copy link
Copy Markdown

PER-16340

PER-16339

PER-16681

@github-actions

github-actions Bot commented Oct 2, 2026

Copy link
Copy Markdown

Dependency Security Audit

Scanned: pyproject.toml dependencies + dev group, resolved at Python 3.10 (the current resolution, and the lowest versions the published specs permit under each pydantic major)

✅ No known vulnerabilities found.

Both the resolved dependency set and the lowest versions the published specs permit are clean at HIGH and CRITICAL.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
zeevmoney and others added 4 commits October 2, 2026 18:31
With X-Wait-Timeout: 0 the PDP's wait times out at once, so the
policy decides the answer: "ignore" returns the write's response and
"fail" answers 424 for every write that waits. The docs said only that
0 makes the PDP answer without waiting.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@zeevmoney
zeevmoney added this pull request to stack #145 October 2, 2026 18:24

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant