Skip to content

Membership-based tenant authorization, tenant lookup, idempotent tenant creation - #6820

Merged
gz merged 25 commits into
mainfrom
tenant-get-and-idempotent-create
Aug 8, 2026
Merged

Membership-based tenant authorization, tenant lookup, idempotent tenant creation#6820
gz merged 25 commits into
mainfrom
tenant-get-and-idempotent-create

Conversation

@gz

@gz gz commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Tenant management follow-ups and membership-based authorization:

  1. GET /v0/tenants/{tenant_id} retrieves one tenant by name or UUID, and
    POST /v0/tenants becomes idempotent (200 with the existing tenant instead
    of 409; both responses carry TenantInfo).
  2. Empty and whitespace-only tenants claim entries are treated as an absent
    claim instead of creating a tenant literally named the empty string.
  3. Membership rows record provenance (origin, created_at), and new DB
    operations serve membership-driven logins.
  4. The membership table authorizes every human login; the tenancy strategies
    (claims, issuer, per-sub) only provision rows, gated by the new
    provision_on_login flag (default true). GET /v0/config/session
    answers headerless multi-tenant logins with the membership list.
  5. Python scenario matrix for both flag states.
  6. The web console drives tenant selection from session memberships: tenant
    picker, id-based switching, per-user saved selection, no-access notice.
  7. fda gains --tenant/FELDERA_TENANT and an explicit
    authentication-failed message for bodyless 401s.
  8. Authorization guide rewrite, migration runbook, revocation semantics,
    changelog.
  9. RBAC audit lines log at debug.

Note for reviewers: the regenerated TypeScript client picks up staleness from
main unrelated to this branch (soft_delete, Iceberg fields, the diff
endpoint's write role); the checked-in generated files had drifted from
openapi.json.

Checklist

  • Unit tests added/updated
  • Integration tests added/updated
  • Documentation updated
  • Changelog updated

Breaking Changes?

Mark if you think the answer is yes for any of these components:

Describe Incompatible Changes

  • POST /v0/tenants no longer answers 409 on a duplicate name (200 with the
    existing tenant instead), and its response schema is TenantInfo;
    NewTenantResponse is removed.
  • GET /v0/config/session: tenant_id, tenant_name, and role are
    nullable; a memberships array is added.
  • Narrowing a token's tenants claim no longer revokes access: membership
    rows from past logins stay live, and with provision_on_login enabled a
    removed member is re-enrolled while the claim still names the tenant. The
    changelog carries the operator guidance, including the pre-upgrade
    membership audit.
  • Some login-path refusals answer 403 or 400 where they answered 401, and a
    claim entry a multi-tenant user does not select no longer creates a missing
    tenant at login.

@mythical-fred mythical-fred left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

High-level (draft) — architecture and semantics only, saving nits for ready-for-review.

The pivot to memberships-as-authority, with the tenancy strategies (claims / issuer / per-sub) reduced to provisioning, is the right shape. Revoking an admin-granted row now sticks across logins instead of being resurrected by the next token, and enroll_in_existing_tenants keeps a passively-listed claim entry from silently minting a tenant with the logger-in as its admin. insert_membership_if_absent closes the admin-grant race between the get-role read and the upsert write. All good moves.

A few things I'd want addressed before this leaves draft:

  1. Audit downgrade to debug is a regression. The old audit() reasoning was explicit: privileged access (admin, owner) logs unconditionally at info because "those are the requests an operator has to be able to account for after the fact". This PR keeps the audit call but drops the info branch, so every default-info deployment loses its cross-tenant owner trail and its per-tenant admin trail. The membership rework doesn't change that operators still need to see who invoked member-management or crossed tenants. Please keep admin/owner at info (or add a separate high-signal audit target); demoting the whole thing to debug trades a real operability property for log volume that operators had already accepted.

  2. UnresolvedActingTenant sentinel installs a real principalAuthenticatedPrincipal { acting_tenant: DEFAULT_TENANT_ID, role: Read, .. } — and relies on is_session_request being called at auth time to gate its creation. That's fine today: the RBAC table treats /v0/config/session as reachable and no other route can be entered by that sentinel because bearer_auth only installs it on that exact (GET, /v0/config/session). But the fragility is that the sentinel principal is indistinguishable downstream from a genuine Read user in the default tenant. If anyone later broadens the gate (e.g. /v0/config/*) or a middleware ordering change lets the sentinel leak into a handler that reads acting_tenant, the failure mode is "unauthenticated caller acts as reader in the default tenant" — silent, not a 4xx. I'd feel better with either a dedicated principal variant (Principal::Unresolved { identity, memberships }) or an explicit assertion in the session handler that rejects any request that doesn't carry the UnresolvedActingTenant extension when it's supposed to. As is, "safe by inspection of the current call sites" is more fragile than the rest of this refactor deserves.

  3. NotATenantMember and unknown-tenant folded into one response is deliberate (existence oracle argument, called out in the code) and I agree with it — noting for the record so a future reviewer doesn't try to "improve" the message by splitting them again. Please leave a comment on select_acting_tenant to that effect; the reasoning currently lives only in the DBError variant doc.

  4. provision_on_login=false + no owner correctly refuses to start, and the error message names the fix. Good. Consider also refusing provision_on_login=false at the AuthProviderType::None boundary being permitted: it's inert, as your test asserts, but it's also nonsensical config and silently ignoring it is a small footgun for future auth-provider transitions. A warning would do.

  5. Migration V36 is nullable-add, backward compatible, and the comment correctly documents "rows from before this migration keep NULL". Good. Consider whether you want a one-time backfill for the pre-existing derived rows created by past logins (i.e., populate origin='derived' for rows the derivation would have produced) — otherwise the list_tenant_members audit view will show origin=None forever for the population most operators care about tracing.

Not blocking (all draft): empty/whitespace-claim filtering with trim(), idempotent POST /v0/tenants returning 200, GET /v0/tenants/{tenant_id} accepting either name or UUID, the --tenant flag / FELDERA_TENANT env / bodyless-401 message on fda, and the console TenantPicker + switchTenant + mustPickGate split all look sensible on skim. Full pass when you flip to ready-for-review.

@gz
gz force-pushed the tenant-get-and-idempotent-create branch 5 times, most recently from 8120763 to 23a23b8 Compare August 7, 2026 04:09
@gz
gz marked this pull request as ready for review August 7, 2026 04:10
@Karakatiza666
Karakatiza666 force-pushed the tenant-get-and-idempotent-create branch from 316cdfb to eea646c Compare August 7, 2026 18:09
@Karakatiza666

Copy link
Copy Markdown
Contributor
image

@mythical-fred mythical-fred left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-review of the eleven commits since 316cdfb — approving.

The UserInfo work (crates/pipeline-manager/src/oidc/userinfo.rs, oidc/destination.rs) is the meatiest addition and it lands well. Highlights worth calling out:

  • Subject-mismatch guard: if info.sub != subject { return Err(UserInfoError::SubjectMismatch) } — exactly the right check per OIDC Core §5.3.2, and the module doc explains why ("either broken or being impersonated; either way the profile must not be stored against this user"). Nice.
  • SSRF hardening in destination.rs: is_public_ipv4 explicitly rejects 169.254.169.254 (cloud metadata) and the CGNAT / benchmarking / 240.0.0.0/4 reserved ranges. IPv4-mapped IPv6 (::ffff:a.b.c.d) is judged by IPv4 rules — the correct call, since it does reach an IPv4 destination. validate_credential_destination requiring https except for loopback (and rejecting the classic localhost.evil.example.com trap in test) is well-thought.
  • Refresh cache under lock: claim_refresh claims + records in one step, so a burst of parallel authenticated requests dedupes to a single UserInfo fetch. The doc comment says this explicitly.
  • deserialize_bool_or_string for AWS Cognito's stringly boolean is a pragmatic accommodation with a good comment explaining why the fallback reads as unverified rather than dropping the whole record.
  • terminal_safe in crates/fda/src/util.rs: bidirectional overrides (U+202A..U+202E, U+2066..U+2069) get scrubbed alongside C0/C1/DEL. The test for \u{202e}moc.live@nimda is the right kind of nasty. Note that char::is_control() also folds CR/LF, so multi-line display names collapse to a single row — the correct behaviour for a table cell.

MembershipOrigin (claim / derived / api) and idempotent POST /v0/tenants are the right shape for the audit story. The V36 migration keeping pre-existing rows at NULL provenance ("their provenance is unknown") is honest.

One minor observation, non-blocking: USER_PROFILE_CACHE_CAPACITY = 4096 — on an installation with more than ~4k concurrently active federated users per issuer, evictions will trigger occasional redundant UserInfo fetches. Given the TTL and the doc note that eviction only costs a redundant refresh, this is fine, but worth remembering if a large-tenant deployment reports auth-path latency spikes.

Not blocking anything.

@Karakatiza666
Karakatiza666 force-pushed the tenant-get-and-idempotent-create branch 2 times, most recently from d70d723 to 012396c Compare August 7, 2026 19:57

@mythical-fred mythical-fred left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rebase-only refresh over main; commit sequence identical to prior approved tip eea646cf57 (same subjects, new SHAs). Re-approving to relight the button on the current base.

@Karakatiza666 Karakatiza666 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

With my edits combined, the UI changes LGTM

@gz
gz added this pull request to the merge queue Aug 8, 2026
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to failed status checks Aug 8, 2026
@gz
gz added this pull request to the merge queue Aug 8, 2026
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to failed status checks Aug 8, 2026
@gz
gz added this pull request to the merge queue Aug 8, 2026
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to failed status checks Aug 8, 2026
@gz
gz added this pull request to the merge queue Aug 8, 2026
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to a conflict with the base branch Aug 8, 2026
@gz
gz enabled auto-merge August 8, 2026 18:10
gz added 4 commits August 8, 2026 11:19
GET /v0/tenants/{tenant_id} retrieves one tenant by a selector that is
either its name or its UUID. A UUID resolves by id only, never as a
name, the same contract as the Feldera-Tenant header. POST /v0/tenants
now answers a duplicate name with 200 and the existing tenant instead
of 409; both responses carry TenantInfo, replacing NewTenantResponse.
Requested by a customer whose operator reconcile loop provisions one
tenant per Kubernetes namespace and previously had to list all tenants
and filter by name.

The explicit-create path folds into the login path's atomic
get-or-create, which now returns the full row and a created flag.
get_tenant serves the selector lookup; resolve_tenant_selector
delegates to it.

Clients: fda gains `tenant get` and `tenant create`, the Python SDK
gains get_tenant and create_tenant, and the web-console client is
regenerated. The regeneration also catches up on staleness from main:
soft_delete, Iceberg fields, and the diff endpoint's write role were
missing from the generated TS client.

The changelog's Unreleased section moves entries that already shipped
under their release headings (v0.327.0, v0.325.0).

Tests: DB-level idempotency and selector-precedence tests
(mutation-validated), proptest model actions for the new Storage
methods, RBAC route-table pins, and a platform scenario asserting
201/200 creation, lookup by name and id, and that a UUID selector
never resolves a name.

Signed-off-by: Gerd Zellweger <mail@gerdzellweger.com>
An IdP claim template that evaluates empty emits `tenants: ""`, which
became a one-element list naming the empty string, so the login
get-or-created a tenant literally named "" that every misconfigured
user shared. Empty and whitespace-only entries, in the array form, the
comma-separated string form, and the deprecated singular `tenant`
claim, now fall through to tenant derivation like an absent claim, and
entries are trimmed.

Signed-off-by: Gerd Zellweger <mail@gerdzellweger.com>
Membership rows gain `created_at` and `origin` (`claim`, `derived`, or
`api`; NULL for rows predating the migration), recorded on creation and
kept across role changes, so an operator can audit how access came to
be. `TenantMember` reports the origin.

New operations for membership-driven logins: `list_user_memberships`
lists the tenants a user may act in by `(provider, subject)`;
`enroll_in_existing_tenants` enrolls a user into listed tenants without
creating tenants or touching existing roles; `find_tenant_id_by_name`
looks a tenant up by name alone, so a name that parses as a UUID is not
reinterpreted as an id. `resolve_login`'s auto-enrollment inserts only
if absent, so a concurrent admin grant wins. New error variants answer
membership-based logins: `NotATenantMember` (one neutral 403 for an
unknown tenant and a tenant the user is no member of),
`AmbiguousTenantMembership`, and `NoTenantMemberships`.

The pre-provision endpoint doc no longer calls grants dormant: a
membership authorizes on its own once the identity authenticates.

The same migration adds the `app_user` profile columns that a later
commit fills from the identity provider: `email_verified`, `display_name`,
and the bookkeeping for how often to ask (`profile_refreshed_at`,
`profile_auth_time`). One migration per branch, so the schema for both
arrives together.

Signed-off-by: Gerd Zellweger <mail@gerdzellweger.com>
The membership table now decides which tenants a human login may act
in; the tenancy strategies (`tenants` claim, issuer tenant, per-sub
tenant) only provision rows at login, gated by the new
`provision_on_login` flag (default `true`,
`FELDERA_AUTH_PROVISION_ON_LOGIN`). With the flag on, the deliberately
selected claim entry is fully provisioned and other listed entries
enroll into existing tenants only, so a mangled claim entry cannot mint
a tenant. With the flag off, a login creates nothing and enrolls no
one: access comes solely from rows granted through the RBAC endpoints,
and startup refuses the flag without a configured owner, because no
principal could otherwise ever grant access.

Acting-tenant selection mirrors the federated path: a `Feldera-Tenant`
header resolves by UUID or name and must match a membership, with one
neutral 403 for an unknown tenant and a non-membership, so the login
path is no tenant-existence oracle; a sole membership lands without a
header; several memberships without one are refused.
`GET /v0/config/session` is the one route that answers such a login: it
reports a `memberships` array and, when no acting tenant resolved,
`null` acting-tenant fields, which drives the web console's tenant
picker. Owners keep platform-wide access; with the flag off an owner
home tenant is looked up by name and never created.

Narrowing a claim no longer revokes access, and with the flag on a
removed member is re-enrolled by their next login while the claim still
names the tenant. Regenerates openapi.json and the console's generated
client.

Signed-off-by: Gerd Zellweger <mail@gerdzellweger.com>
gz and others added 21 commits August 8, 2026 11:19
Nine ordered scenarios cover both flag states: membership grants reach
beyond the claim, unknown and unjoined tenants answer alike, headerless
ambiguity and the session picker payload, two-lever revocation under
provisioning, passive claim entries enroll but never create, empty
claims derive the personal tenant, and, with provisioning off, logins
are denied until granted, removal revokes, and claims are ignored.

Signed-off-by: Gerd Zellweger <mail@gerdzellweger.com>
The switcher and the new must-pick tenant picker read the session's
`memberships` instead of decoding the token's `tenants` claim, and
select by tenant id, which stays valid across renames and disambiguates
names that parse as UUIDs (a Cognito sub-derived personal tenant). The
picker preserves deep links by reloading in place; the profile-menu
switcher restarts at the home page. A login with no membership sees a
no-access notice instead of a broken UI.

The saved selection is keyed per user and survives logout; an invalid
selection (membership removed, or an owner's acted-as tenant deleted)
clears itself and retries once headerless. Losing the last membership
is different: no fetch is pending to carry that news and a reload lands
in the same place, so the global error interceptor records it and the
gate engages on that, which also parks the pollers rather than letting
them spray failing requests until the user happens to refresh. Pollers
pause while no acting tenant is resolved. The remove-member dialog
states when removal takes effect and that API keys and OIDC trusts
survive it.

Signed-off-by: Gerd Zellweger <mail@gerdzellweger.com>
`--tenant` / `FELDERA_TENANT` sends the `Feldera-Tenant` header on
every request, which a platform owner or a user with several
memberships needs; the selector is not a credential and travels on any
scheme.

`fda member list|add|set-role|remove` covers the tenant's membership
endpoints, so an owner who creates a tenant from the CLI can also grant
access from it rather than dropping to curl for half the workflow.
`add` takes an identity rather than a user id, because the point is to
grant access before that user's first login.

A bodyless 401, what the auth middleware answers when no credentials
arrived, now reads "authentication failed" with a hint that fda sends
credentials only over https, instead of the misleading version-mismatch
notice.

Signed-off-by: Gerd Zellweger <mail@gerdzellweger.com>
The authentication guide describes memberships as the authorization
authority and the tenancy strategies as login provisioning, marks
managed tenancy deprecated, and adds the runbook for migrating to
Feldera-managed memberships. The roles page gains a "Revoking access"
section with the two-lever semantics under provisioning. Changelog
entries cover the model, the revocation breaking change, and rename
decoupling.

Signed-off-by: Gerd Zellweger <mail@gerdzellweger.com>
One line per request drowned the log at info, and every request is
privileged on deployments such as auth-off development mode. A
dedicated audit sink will carry these separately.

Signed-off-by: Gerd Zellweger <mail@gerdzellweger.com>
A membership is keyed to an OIDC subject, which is rarely a string
anyone recognizes, and an access token is a poor place to look up a
person's name and email: the claims a provider puts there vary, and an
AWS Cognito access token carries neither however many scopes it was
granted. Read them from the provider's UserInfo endpoint (OpenID Connect
Core 1.0 section 5.3) instead, and report them in `TenantMember` as
`display_name`, `email`, and `email_verified`.

Feldera holds no session, so there is no login moment to hang the fetch
on. The token's `auth_time` supplies one: it records when the user
authenticated and survives token refresh, so a newer value marks a fresh
login, which is when a changed email appears. Providers publishing no
`auth_time` fall back to a daily refresh. Two gates keep one login to
one request: an in-memory claim settles the common case without touching
the database, and a single atomic upsert then settles it across
api-server replicas and across restarts. The fetch runs detached, so a
provider that is slow or down costs the request nothing and never turns
a valid token away.

An address the provider vouched for now outranks both an access-token
claim and the email an administrator types into `preprovision_member`,
neither of which carries verification: overwriting would strip a
verified address of its mark or leave the mark beside a different one.
`email_verified` is false unless a provider says otherwise, so a
provider saying nothing never reads as an endorsement.

`email_verified` is a boolean in the specification but AWS Cognito
answers with the strings "true" and "false"; both spellings are now
accepted. A token using the string form previously failed to decode at
all, so such a provider could not authenticate.

The UserInfo endpoint comes out of the issuer's discovery document,
which the issuer controls, so a tenant-registered issuer is held to the
same destination policy as its `jwks_uri`. The answer's `sub` is checked
against the token's before anything is stored (section 5.3.2). Owner
matching deliberately still reads the token alone, so who is an owner
never depends on whether a background fetch has landed.

Signed-off-by: Gerd Zellweger <mail@gerdzellweger.com>
The member list led with an OIDC subject, which identifies a person to
the identity provider and to nobody else. Lead with the name the
provider spells instead, falling back to the email and then to the
subject, and show the email beside it.

A check mark follows an email the provider vouches for. Nothing follows
one it will not: an address typed into the pre-provisioning form is a
claim about an address, and so is one from a provider that answers
nothing either way, and neither earns a mark.

Signed-off-by: Gerd Zellweger <mail@gerdzellweger.com>
`member list` gains `name` and `verified` columns, so an administrator
reads a member's identity without decoding an OIDC subject and can tell
an address the provider vouches for from one an administrator typed.

Signed-off-by: Gerd Zellweger <mail@gerdzellweger.com>
Document where a member's name, email, and verification verdict come
from, and record in the owners note that owner matching reads the access
token alone: many providers put neither `email` nor `email_verified`
there, so an email entry cannot match on such a provider.

Signed-off-by: Gerd Zellweger <mail@gerdzellweger.com>
The dev issuer answered `/userinfo` by echoing the token's own claims,
which cannot exercise a profile lookup: the point of the endpoint is
that a provider knows more than the token carries. Its demo identities
now have names and their own verification verdicts, answered from the
profile rather than the token, covering the three shapes a real provider
sends: the boolean of the specification, the string AWS Cognito answers
with, and a provider that will not vouch for the address.

Tokens carry `auth_time`, set when a role is picked and preserved across
refresh, as a real provider issues it. `--omit-token-email` keeps
`email` and `email_verified` out of access tokens, as an AWS Cognito
access token does, leaving UserInfo as the only source.

The access log was block-buffered and sat empty for minutes at a time
under `rbac_up.sh`, which redirects it to a file; it is the only record
of what a browser asked the issuer for, so line-buffer it.

`rbac_demo.py` keys members by subject rather than email: the subject is
the identity itself, whereas the email is display data a provider may
not put in an access token at all.

Signed-off-by: Gerd Zellweger <mail@gerdzellweger.com>
`oidc_http_client` takes about 90 ms per call, because `use_rustls_tls()`
loads and parses the platform's root certificate store every time. Every
OIDC fetch built a fresh one, so resolving an issuer's keys spent
roughly 180 ms constructing clients before a single byte moved:
discovery built one, the JWKS fetch built another. That sat on the
authentication path, once per JWKS cache miss per issuer.

Only two clients differ, by whether the destination policy restricts DNS
to public addresses, so hold both in `ServerState` and hand out a
reference. Reuse also keeps the connection pool, so a repeat fetch to
the same issuer skips the TLS handshake as well.

The measurement, on this branch:

    oidc_http_client (use_rustls_tls): 89.761995ms per build
    Client::builder().build() default: 85.657µs per build

Signed-off-by: Gerd Zellweger <mail@gerdzellweger.com>
…request

The profile refresh presents the caller's own live access token, and the
endpoint it presents it to comes out of the issuer's discovery document.
Only a tenant-registered issuer had that URL checked, so a login provider
whose discovery named an `http://` UserInfo endpoint would have had the
token sent in the clear, replayable by anyone on that route for the rest
of its lifetime.

The earlier code held the endpoint to the same policy as `jwks_uri`,
which was the wrong model to copy: a JWKS is signed and costs nothing to
disclose, whereas this request carries a credential. `validate_credential
_destination` states the stricter rule and runs in every mode before the
`Authorization` header is attached.

Loopback is the exception, because nothing off the host can observe it
and an identity provider on `localhost` is the ordinary shape of a
development deployment. A name that merely resembles loopback does not
qualify, and a private address is still refused over cleartext: it is
observable on that network.

The regression test asserts through a mock that expects zero requests,
so it proves the request is never made rather than merely that it fails.

Signed-off-by: Gerd Zellweger <mail@gerdzellweger.com>
A table cell goes straight to stdout, and much of what fills one is
written by somebody else: a member's name and email come from the
identity provider, a pipeline's name from another member of the tenant,
a tenant's name and a trust's description from whoever created them. An
escape sequence in that text was acted on by the terminal instead of
shown, letting one user redraw what an administrator sees, or reach
whatever else that terminal binds to a control sequence.

`util::terminal_safe` replaces every control character, and the
bidirectional overrides that reorder a line without leaving a visible
trace. Ordinary text, accents and scripts of every direction included,
is untouched. It now covers the member, tenant, trust, API-key and
pipeline listings, the server error text that quotes names the caller
did not choose, and the echo of a just-created object.

Three things stay verbatim on purpose, and the helper's documentation
says so: the `logs` byte stream and ad-hoc query results are the
program's own output, which is the point of asking for them; and
`--format json` reports exactly what the server said, escaped by the
JSON encoder, because machine consumers need the original bytes. The
monitor tables needed nothing either, rendering through `Debug` and
`serde_json`, both of which already escape.

Signed-off-by: Gerd Zellweger <mail@gerdzellweger.com>
…t selection

Avoid showing empty selection of tenants on admin page

Refactor how possible tenants are selected in profile menu

Signed-off-by: Karakatiza666 <bulakh.96@gmail.com>
…y introducing the 'authorized' route group

This change lets all not directly related UI components not have to know that a tenant may not be selected

Other minor fixes and style changes

Signed-off-by: Karakatiza666 <bulakh.96@gmail.com>
The default retry budget (3 retries, exponential backoff) gives up after
about 14 seconds. Infrastructure churn on shared CI instances, such as a
node replacement or a pipeline pod rescheduling, outlasts that budget and
fails tests with 503 PipelineUnavailable even though the instance recovers.

Add RetryConfig.deadline_seconds: when set, retries continue with the usual
waits until the wall-clock budget is spent, and max_retries no longer
applies. An outage lasts for a duration, not a number of requests.

The shared test client retries for up to 300 seconds, sized to ride out a
node replacement.

Signed-off-by: Gerd Zellweger <mail@gerdzellweger.com>
A connection dropped by an API-server restart failed any in-flight POST
immediately: only GET was assumed safe to retry. Pipeline actions (start,
pause, stop, activate, dismiss_error, connector pause/start) are
desired-state setters, and PUT/DELETE of a pipeline are idempotent by API
contract, so a repeat cannot change the outcome. Callers now mark such
requests idempotent=True and dropped connections retry.

A DELETE marked idempotent that sees 404 on a retry attempt reports
success: the resource vanished between attempts, so an earlier attempt was
applied and the postcondition (resource absent) holds.

Signed-off-by: Gerd Zellweger <mail@gerdzellweger.com>
The shared CI instances see node replacement and pipeline pod rescheduling
that no request-level retry can fully absorb: a stream cut mid-body or a
pipeline that stays unavailable past the retry budget still kills the test.

Add pytest-rerunfailures with an allowlist of transport failures and
pipeline unavailability. Assertion failures never match the allowlist, so
real bugs still fail deterministically.

Signed-off-by: Gerd Zellweger <mail@gerdzellweger.com>
A 503 signals a transient condition (pipeline pod rescheduling, runner
restart, compiler busy, database lock contention) that resolves within
seconds, but clients had to guess how long to back off. All error types now
build their response through one helper that attaches Retry-After: 5 to
503s, sized to the runner's fastest pipeline-status probe interval and its
pipeline-descriptor cache TTL.

The Python SDK already honors Retry-After, so its 503 retries switch from
an exponential ramp toward 64 seconds to the server-chosen interval.

Signed-off-by: Gerd Zellweger <mail@gerdzellweger.com>
…test

The 300-second retry budget belongs to shared CI instances only:
enterprise_only gates call get_config at import time, so with no local
instance running the deadline turned test collection into a 5-minute hang.

The output-buffer size-limit test pipeline peaks near 2 GiB while buffering
10M+ records for the Delta sink, but requested the default 1 GiB; the
kubelet evicted it when a packed CI node ran out of memory, failing the
test with PipelineUnavailable. Reserve 4 GiB.

Signed-off-by: Gerd Zellweger <mail@gerdzellweger.com>
Signed-off-by: Gerd Zellweger <mail@gerdzellweger.com>
@gz
gz force-pushed the tenant-get-and-idempotent-create branch from 0183786 to 031ece6 Compare August 8, 2026 18:23
@gz
gz added this pull request to the merge queue Aug 8, 2026

@mythical-fred mythical-fred left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-review after 7 new commits since my 2026-08-07 approval (tip 031ece69).

Scope note: commits b9d4feb through 031ece6 are a python-SDK retry refactor plus a pipeline-manager Retry-After change. They're not membership auth. Each commit message stands on its own, but the PR title is now misleading and the diff-to-review balloons. Consider splitting the retry work into a follow-up PR — it will land faster and this one stays coherent.

The retry changes themselves look correct: stop_after_delay propagates through the single Retrying loop (no recursion silently discards the budget); idempotent=True is only stamped on true desired-state setters and idempotent upserts; the DELETE-404-on-retry short-circuit is guarded by attempt_number > 1; the fixed 5s Retry-After is documented against the runner's probe interval + descriptor cache TTL. Tests cover each of these.

A few small things inline — none blocking. Approval from 012396c6 still stands.

is_idempotent
and http_method is requests.delete
and err.status_code == 404
and attempt.retry_state.attempt_number > 1

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The attempt_number > 1 guard is what makes this safe — a first-attempt 404 still raises, so we only claim success when a prior attempt could plausibly have applied the DELETE. Good. One thought: this special case is DELETE-only today, but PUT/PATCH on a resource that then vanished (server-applied, response lost, resource then deleted by someone else) would raise. Probably correct — you don't want to silently succeed a PUT against a resource that isn't there anymore — but worth a one-line comment saying "DELETE is the only method where 'gone' is the desired postcondition", so a future reader doesn't extend the branch to other verbs by analogy.

# A wall-clock deadline (when configured) replaces the attempt cap:
# transient outages last for a duration, not a number of requests.
stop = (
stop_after_delay(cfg.deadline_seconds)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

stop_after_delay measures from the first attempt inside this Retrying, so a caller-set deadline is honored exactly once per send_request. The 401-re-resolve fallback below (~line 309) then does one more _do_single_request outside the retryer — that's fine (it's a single request, not a retry loop), but if you ever add retries to that path, they'd bypass the deadline. Worth a note in the 401 branch.

Comment thread python/pyproject.toml
# still fail deterministically.
addopts = [
"--reruns=2",
"--only-rerun=FelderaCommunicationError",

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

--only-rerun matches on the exception's string representation, so anything containing these substrings in the traceback text will rerun. FelderaCommunicationError and PipelineUnavailable are tight; ChunkedEncodingError / ProtocolError / ArrowInvalid are broader and could conceivably hide a real bug that surfaces as e.g. a schema-shaped ArrowInvalid. Not blocking — reruns are visibly reported in the test output — but keep an eye on which reruns actually flip green vs. still-fail. If a rerun pattern always passes second try, it's masking flakiness worth diagnosing; if it often still fails, the pattern is doing its job.

}
}

/// Seconds a client should wait before retrying a 503. Matches the runner's

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

5 seconds as a fixed Retry-After is a reasonable floor for the two cases you cite (probe interval, descriptor cache TTL). Two thoughts, both non-blocking: (1) The python client's _custom_wait caps Retry-After at max_backoff (default 64s) — fine here, but if the server ever wants to advise a longer wait during e.g. a rolling upgrade, the cap will silently truncate it. Worth remembering when tuning. (2) A constant Retry-After from every 503 source is a slight lie for cases where the wait is really longer (e.g. PipelineShuttingDown), but the client will just retry once, get 503 again, and back off — safe, just noisy. If it ever becomes a problem, thread the actual expected duration through the specific RunnerError variant.

# is not running would hang local test collection.
retry_config=(
RetryConfig(deadline_seconds=300.0)
if os.environ.get("CI")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

300s CI deadline is a big hammer but a clear one — a node replacement fits easily inside it and the local-collection branch keeps developer imports fast. Good rationale in the comment.

Merged via the queue into main with commit 42639f0 Aug 8, 2026
1 check passed
@gz
gz deleted the tenant-get-and-idempotent-create branch August 8, 2026 19:53
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.

4 participants