Membership-based tenant authorization, tenant lookup, idempotent tenant creation - #6820
Conversation
mythical-fred
left a comment
There was a problem hiding this comment.
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:
-
Audit downgrade to
debugis a regression. The oldaudit()reasoning was explicit: privileged access (admin,owner) logs unconditionally atinfobecause "those are the requests an operator has to be able to account for after the fact". This PR keeps the audit call but drops theinfobranch, so every default-infodeployment loses its cross-tenantownertrail and its per-tenantadmintrail. The membership rework doesn't change that operators still need to see who invoked member-management or crossed tenants. Please keepadmin/owneratinfo(or add a separate high-signal audit target); demoting the whole thing todebugtrades a real operability property for log volume that operators had already accepted. -
UnresolvedActingTenantsentinel installs a real principal —AuthenticatedPrincipal { acting_tenant: DEFAULT_TENANT_ID, role: Read, .. }— and relies onis_session_requestbeing called at auth time to gate its creation. That's fine today: the RBAC table treats/v0/config/sessionas reachable and no other route can be entered by that sentinel becausebearer_authonly installs it on that exact(GET, /v0/config/session). But the fragility is that the sentinel principal is indistinguishable downstream from a genuineReaduser 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 readsacting_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 theUnresolvedActingTenantextension 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. -
NotATenantMemberand 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 onselect_acting_tenantto that effect; the reasoning currently lives only in theDBErrorvariant doc. -
provision_on_login=false+ no owner correctly refuses to start, and the error message names the fix. Good. Consider also refusingprovision_on_login=falseat theAuthProviderType::Noneboundary 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. -
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
derivedrows created by past logins (i.e., populateorigin='derived'for rows the derivation would have produced) — otherwise thelist_tenant_membersaudit view will showorigin=Noneforever 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.
8120763 to
23a23b8
Compare
316cdfb to
eea646c
Compare
mythical-fred
left a comment
There was a problem hiding this comment.
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_ipv4explicitly 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_destinationrequiring https except for loopback (and rejecting the classiclocalhost.evil.example.comtrap in test) is well-thought. - Refresh cache under lock:
claim_refreshclaims + 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_stringfor 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_safeincrates/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@nimdais the right kind of nasty. Note thatchar::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.
d70d723 to
012396c
Compare
mythical-fred
left a comment
There was a problem hiding this comment.
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
left a comment
There was a problem hiding this comment.
With my edits combined, the UI changes LGTM
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>
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>
0183786 to
031ece6
Compare
mythical-fred
left a comment
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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) |
There was a problem hiding this comment.
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.
| # still fail deterministically. | ||
| addopts = [ | ||
| "--reruns=2", | ||
| "--only-rerun=FelderaCommunicationError", |
There was a problem hiding this comment.
--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 |
There was a problem hiding this comment.
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") |
There was a problem hiding this comment.
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.

Tenant management follow-ups and membership-based authorization:
GET /v0/tenants/{tenant_id}retrieves one tenant by name or UUID, andPOST /v0/tenantsbecomes idempotent (200 with the existing tenant insteadof 409; both responses carry
TenantInfo).tenantsclaim entries are treated as an absentclaim instead of creating a tenant literally named the empty string.
origin,created_at), and new DBoperations serve membership-driven logins.
(claims, issuer, per-sub) only provision rows, gated by the new
provision_on_loginflag (defaulttrue).GET /v0/config/sessionanswers headerless multi-tenant logins with the membership list.
picker, id-based switching, per-user saved selection, no-access notice.
fdagains--tenant/FELDERA_TENANTand an explicitauthentication-failed message for bodyless 401s.
changelog.
Note for reviewers: the regenerated TypeScript client picks up staleness from
main unrelated to this branch (soft_delete, Iceberg fields, the diff
endpoint's
writerole); the checked-in generated files had drifted fromopenapi.json.Checklist
Breaking Changes?
Mark if you think the answer is yes for any of these components:
Describe Incompatible Changes
POST /v0/tenantsno longer answers 409 on a duplicate name (200 with theexisting tenant instead), and its response schema is
TenantInfo;NewTenantResponseis removed.GET /v0/config/session:tenant_id,tenant_name, androlearenullable; a
membershipsarray is added.tenantsclaim no longer revokes access: membershiprows from past logins stay live, and with
provision_on_loginenabled aremoved member is re-enrolled while the claim still names the tenant. The
changelog carries the operator guidance, including the pre-upgrade
membership audit.
claim entry a multi-tenant user does not select no longer creates a missing
tenant at login.