test(flagd): run the provider conformance suite against both resolvers - #411
Draft
aepfli wants to merge 3 commits into
Draft
test(flagd): run the provider conformance suite against both resolvers#411aepfli wants to merge 3 commits into
aepfli wants to merge 3 commits into
Conversation
The normative control path for any provider with a real backend. HttpControl
drives the endpoints in control-api.yaml -- /start, /stop, /restart, /change and
the optional /reset -- so a containerised provider can adopt the suite without
writing its own control client, and so another language's TCK drives the same
endpoints against the same stack and must get the same answers.
Built on urllib.request alone. The TCK gains no HTTP client and no container
dependency: orchestrating the stack stays with the adopting suite, where the
vendor-specific knowledge already lives. That is a deliberate trade against the
"provider authors write no test infrastructure" goal, and worth revisiting once
a second containerised adopter shows what is actually common -- abstracting from
one example tends to produce the wrong abstraction.
Two behaviours carry the isolation guarantee:
* prepare_scenario prefers POST /reset, which restores the flag baseline with
no availability blip and so cannot inject a spurious lifecycle event into
the next scenario. It is optional; a backend without it answers 404 or 501
and the client falls back to POST /start?config=default. The probe happens
once per suite and is remembered. flagd-testbed's launchpad registers only
/start, /restart, /stop and /change, so the fallback is the normal path.
* After a disconnect the backend may be down, and /reset is specified to
restore flag state rather than to start a stopped backend, so a disconnect
is recorded and the scenario following one is prepared with /start.
Both are invisible from inside a scenario -- a control that silently did nothing
would leave each scenario running against whatever the previous one left behind,
and the suite would report those results as conformance. So they are pinned by a
self-test against a stubbed control API built on http.server: no Docker, no
network beyond loopback.
Signed-off-by: Simon Schrottner <simon.schrottner@flagsmith.com>
Adopts the OpenFeature provider conformance suite in the flagd provider, for both resolvers, as two separate suites. They are separate because they are separately conformant. flagd resolves flags two quite different ways -- RPC evaluates remotely over gRPC, in-process syncs the ruleset and evaluates locally -- and any difference between the two results is a difference an application would see when it switches resolver, which is exactly the class of thing the suite exists to surface. flagd-testbed is not modified and the existing e2e suites are untouched. The TCK drives the testbed's launchpad through the standardised control API, which the launchpad already implements, and reuses the container lifecycle already in tests/e2e. The stack is started once per session and never restarted; scenario isolation comes from the control API instead, because container orchestrators assign host ports dynamically and cannot reliably preserve them across a restart, and a restarted backend on a new port looks like a flaky provider rather than a broken test. One stack and one HttpControl serve both suites. One flagd process serves both resolver ports, so there is nothing a second stack would isolate -- and the control has to be shared, because it tracks whether a disconnect has left the backend down so the next scenario starts it rather than merely resetting flag state. Two instances would each hold half of that knowledge. Every capability is declared on the strength of a line of provider code rather than of a green run, and each declaration carries its file and line. Both resolvers get the same set: EVENTS, STALE, CONFIGURATION_CHANGE, OBJECT, UNAVAILABLE_INIT and STRICT_NUMERIC_TYPING. TARGETING and CACHING are withheld from both, because no scenario carries either tag and a capability nothing exercises would be a claim with no evidence behind it. Worth recording that STALE is declared for RPC. The Go provider's RPC resolver never emits PROVIDER_STALE -- it sends ProviderError directly on connection loss (go-sdk-contrib#939) -- and the Go adoption withholds the capability for that reason. Python has no such asymmetry: both resolvers emit PROVIDER_STALE from the same channel-connectivity callback shape on TRANSIENT_FAILURE, and only escalate to PROVIDER_ERROR once the retry grace period expires. That is the behaviour the specification describes, and it is the reason the grace period is set well above the length of the scenario's outage: too short a value turns a scenario about staleness into one about failure. Signed-off-by: Simon Schrottner <simon.schrottner@flagsmith.com>
The flagd dev group now depends on openfeature-provider-tck as a workspace source, so the lock has to carry it or uv sync --frozen fails for every package in the workspace. Signed-off-by: Simon Schrottner <simon.schrottner@flagsmith.com>
|
Important Draft PR not reviewedDraft PRs are not automatically reviewed by default.
To automatically review draft PRs, update your CodeRabbit configuration: reviews:
auto_review:
drafts: trueThanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
Adopts the conformance suite in the flagd provider, for both resolvers, as two separate suites.
They are separate because they are separately conformant. flagd resolves flags two quite different ways — RPC evaluates remotely over gRPC, in-process syncs the ruleset and evaluates locally with
openfeature-flagd-core— and any difference between the two results is a difference an application would see when it switches resolver. That is exactly the class of thing the suite exists to surface.flagd-testbedis not modified, and the existing flagd e2e suites are untouched. The TCK drives the testbed's launchpad through the standardised control API, which the launchpad already implements, and reuses the container lifecycle already intests/e2e.The adoption
Two modules over one shared
build_config, plus the per-resolver configuration. Each declares its capability set and calls pytest-bdd'sscenarios(features_path())directly — the step definitions arrive through the TCK'spytest11entry point, sotests/tck/needs noconftest.pyfor them, only for the container and control fixtures.The stack is started once per session and never restarted. Scenario isolation comes from the control API instead, because container orchestrators assign host ports dynamically and cannot reliably preserve them across a restart — a restarted backend comes back on a different host port, silently invalidating every provider already pointed at the old one, and the failure looks like a flaky provider rather than a broken test.
One stack serves both suites, because one flagd process serves both ports the resolvers use (8013 RPC, 8015 sync), so there is nothing a second stack would isolate. The
HttpControlis shared for a less obvious reason: it tracks whether a disconnect has left the backend down, so the next scenario starts it rather than merely resetting flag state. Two instances would each hold half of that knowledge.The
@unavailableprovider points at a port discovered by binding and releasing, never at the backend under test — that has to stay up, and simulated outages belong to the control API.HttpControlThe client for the control API in
control-api.yaml, added to the TCK package in this PR because nothing in #409 needed it.It is
urllib.requestonly, so the TCK gains no new dependency — not an HTTP client, and not a container runtime. Orchestrating the stack stays with the adopter, where the vendor-specific knowledge already lives (which compose file, which services, which internal ports). That is a deliberate trade against the "provider authors write no test infrastructure" goal, and worth revisiting once a second containerised adopter shows what is actually common; abstracting from one example tends to produce the wrong abstraction.Two behaviours worth reviewing:
/resetfallback.prepare_scenarioprefersPOST /reset, which restores the baseline with no availability blip and so cannot inject a spurious lifecycle event into the next scenario. It is optional; flagd-testbed's launchpad registers only/start,/restart,/stopand/change, so it answers 404 and the TCK falls back toPOST /start?config=default. The fallback is the normal path here, not the exception. The probe happens once per session and the answer is cached in_reset_supported./startrather than/reset./resetis specified to reset flag state, not to start a stopped backend, soHttpControltracks whether a disconnect happened and starts the backend for the scenario that follows one.Finding: Python's RPC resolver does emit
PROVIDER_STALE, and Go's does notThe Go adoption withholds
Capability.STALEfrom its RPC suite, because Go's RPC resolver sendsProviderErrordirectly on connection loss and never emitsPROVIDER_STALE— filed as go-sdk-contrib#939.Python has no such asymmetry.
resolvers/grpc.py:202-212: the channel-connectivity callback emitsPROVIDER_STALEonTRANSIENT_FAILUREand only then starts athreading.Timerthat escalates toPROVIDER_ERRORonceretry_grace_periodexpires. That is structurally the same staged handling as the in-process watcher atgrpc_watcher.py:178-190.PROVIDER_STALEon connection lossPROVIDER_ERRORSo both Python suites declare the full capability set, including
STALE.This is not a point scored. It is a cross-language behavioural difference in the same provider, against the same specification, and the conformance suite is what made it visible — which is roughly the argument for having one. Whichever way it gets reconciled, it should be reconciled deliberately rather than by accident of implementation.
No
xfailfor thebool-satisfies-IntegerbugThe Python SDK narrows
booltoIntegerin typed evaluation (python-sdk#619), which would ordinarily put theSTRICT_NUMERIC_TYPINGscenarios at risk. It does not affect flagd, because flagd errors first, before the SDK's check is reached:INVALID_ARGUMENT, andgrpc.py:461-462maps that toTypeMismatchError.flagd_core.py:222-223, "For integer type, reject bool (since bool is subclass of int)", raisingTypeMismatchError.So both resolvers declare
STRICT_NUMERIC_TYPINGunconditionally and noxfailis needed. If #619 is fixed, nothing here changes.CI
The paths filter already has an entry for
providers/openfeature-provider-flagd, and #409 added one fortools/openfeature-provider-tck, so no workflow change is needed.But the build workflow will not run on this PR while it is stacked.
.github/workflows/build.ymltriggers onpull_request: branches: [main], and this PR targetsfeat/provider-tck. It gets checks once #409 merges and GitHub retargets this PR tomain. (lint-pr.ymlandcomponent-owners.ymlusepull_request_targetwith no branch filter, so those two do run — a PR-title check and an owner ping, neither of which builds or tests anything.)Worth stating plainly, because it means the flagd suites in this PR have not been executed by anything — not locally, not by CI. The TCK side is verified; see below.
uv.lockis updated in its own commit. The flagd dev group now depends onopenfeature-provider-tckas a workspace source, and without the lock entryuv sync --frozenfails the build job for every package in the workspace, not just flagd.Verification
ruff format --check, both treesruff check, both treespytest testsintools/openfeature-provider-tckuv lock --checktests/tck)mypy tools/openfeature-provider-tckfeat/provider-tckand unrelated to this PR —set[Capability]passed wherefrozenset[Capability]is expected, intest_in_memory_conformance.py:93andtest_controllable_conformance.py:42. This branch adds two source files and no new errors. Belongs on #409.FlagdProviderkeywords used bysuite.pyprovider.py:46-69—resolver_type,host,port,deadline_ms,stream_deadline_ms,retry_backoff_ms,retry_backoff_max_ms,retry_grace_periodall existFlagdContainerAPItests/e2e/flagd_container.py—get_port(ResolverType),get_launchpad_url(),start(),stop()/start?config=,/stop,/restart?seconds=,/change; no/reset, hence the fallbackThe capability sets are the part most likely to need adjusting after the first real run. If a declared capability fails, the fix is to withhold it and file an issue against the provider — not to weaken the scenario.
Worth filing
Two things the reading turned up that are provider bugs rather than suite problems, and are deliberately not fixed here:
In-process emits
PROVIDER_CONFIGURATION_CHANGEDon every reconnect, unconditionally.in_process.py:30-36—_FlagStoreAdapter.update()callsemit_provider_configuration_changedfor every sync payload the watcher applies, including one whosechanged_keysis empty. A reconnect that re-delivers an unchanged ruleset therefore announces a configuration change that did not happen. Handlers that invalidate caches or re-render on that event do unnecessary work, and an application cannot distinguish a real change from a reconnect.RPC passes through
STALEbeforeERRORagainst a backend that was never reachable. The staged handling described above is right for losing a connection, but it also fires when the initial connection never succeeded —STALEmeans "cannot reach the backend but still serving values", and there are no values to serve. This is the likeliest source of@unavailableflakiness on the first real run: the scenario expects a promptPROVIDER_ERROR, and the resolver will insert aretry_grace_period-longSTALEwindow ahead of it. The suites keep short deadlines and a 1s grace period for the unavailable provider precisely to bound this, but bounding it is not the same as fixing it.Not filed as issues from here — flagging them so someone with context on the flagd provider can decide.
What is not here
InProcessControland a different capability set. Left out to keep the reviewable surface small.HttpControlsection.