You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
The JS, Java, and Python client libs have drifted apart. This PR adds clientlibs/CLIENT_LIB_PARITY_AUDIT.md, an audit of all three against each other and the backend /v6 API with a phased reconciliation plan, and implements Phase A (correctness fixes) and Phase B (feature parity). No backend changes; everything uses the existing /v6 API.
Phase A: correctness fixes
Java
markDecisionPoint now sends experimentId and condition id from the cached assignment, across all overloads and Assignment.markDecisionPoint(). Previously Java sent only conditionCode, so the server re-ran experiment selection and could mark a different experiment when several share a decision point.
Within-subjects marks now rotate assignedFactor alongside assignedCondition.
The assignment and feature-flag caches are now filled beforeonSuccess runs. Previously a call chained from inside the callback (e.g. a mark) could hit a cold cache.
First unit test suite (src/test/java, TestNG). It runs ExperimentClient against an in-process HttpServer.
JS
DefaultHttpClient never retried: axios rejects non-2xx responses before the retry branch runs, and that branch would have retried 4xx rather than 5xx anyway. It now retries network errors and 5xx once, after a 300 ms wait, for idempotent requests only (see Retry policy), fails fast on 4xx, and sets a 5s default timeout. Because the retry code never ran before, turning on the old default of 3 retries would have meant up to ~22s of retrying that callers can't turn off, so the default is 1 (DEFAULT_MAX_RETRIES / DEFAULT_RETRY_BACKOFF_MS). Making retries configurable is left for a follow-up.
ISendRewardResponse gains mode / success to match the backend.
markDecisionPoint no longer throws when the assignment cache is null (e.g. after a failed /assign).
Fixed 3 ApiService mark tests that already fail on dev: their expected bodies were missing context.
Python
The _sync methods use ApiService's real sync implementations instead of asyncio.run(), so they work inside a running event loop (Jupyter, async frameworks).
HTTP connections are pooled (see below). Adds close() / aclose() and with / async with support.
Phase B: feature parity
Rewards
Java (previously had none): sendRewardForExperiment(value, experimentId, [awaitResult], cb) and sendRewardForDecisionPoint(value, context, site, target, [awaitResult], cb). Named methods mean a caller can't pass neither identifier. context is explicit, matching JS and Python. A 409 (with awaitResult) is reported to onError and not retried.
JS:sendReward accepts awaitResult, sent as ?awaitResult=true. Python already had it.
Feature-flag session groups (groupsForSession + includeStoredUserGroups), previously JS-only
Java:setFeatureFlagUserGroupsForSession(groups, includeStored) / clearFeatureFlagUserGroupsForSession(). Setter-only, so the constructor overloads don't double.
Python: a feature_flag_user_groups_for_session= constructor kwarg plus set_feature_flag_user_groups_for_session(). Both accept a FeatureFlagUserGroupsForSession model or a dict, and both fields are required.
In all three libs, changing the session groups clears the cached flags.
Dead endpoint methods removed
These were breaking removals, but every call already failed with a 404.
Java addGroupMetrics() / addSingleMetrics(): POST /v6/metric was never a client route. MATHia doesn't use them. The Metric bean stays because the /log response uses it.
Retry policy
Retries are limited to requests that are safe to repeat, in both JS and Java:
Never retried:/mark, /log and /reward. None of them are deduplicated server-side, so resending after a lost response would record the write twice, and a duplicated reward biases adaptive assignment.
JS marks safe requests with an optional retryable flag on the request config, which custom lite-build HTTP clients can also honor. This changes Java's existing behavior: it previously retried /mark and /log once. Python never retries.
Python connection pooling
The sync client is pooled per UpgradeClient. The async client is pooled only inside async with client:, because an AsyncClient must be closed on the loop that opened it. Outside that block each async request uses a short-lived client, same as dev, so successive asyncio.run() calls don't strand connection pools.
Phase C: aligning behavior
Exact decision point matching everywhere. The server's /mark and /reward lookups are case-sensitive. Java matched case-insensitively, so it returned conditions whose marks the server couldn't attribute to the experiment. Python now treats a None target as "".
Caches cleared when the user may have changed. In all three libs, init(), setGroupMembership, setWorkingGroup and setAltUserIds clear the assignment and feature-flag caches once the server confirms the call. Previously only Python's init() cleared anything. Clearing is best-effort: a lookup that was already in flight can still cache a response from before the change. That leaves the cache exactly as stale as dev always did, so it's still a strict improvement, and each README's new "Caching" section tells callers to await these calls before reading, or pass ignoreCache. (A generation-counter guard against that race was built, then removed after review as over-engineering.) Java reads and writes both caches under a lock, since they're written on Jersey threads and dev had no synchronization there; within-subjects rotation and the lookups that copy an assignment out of the cache hold the same lock, so a lookup never sees conditions rotated without their factors. One side effect: within-subjects rotation restarts after one of these calls.
Python Assignment.mark_decision_point() now rotates within-subjects assignments, like the client-level mark and like JS.
Java's "no experiment here" placeholder keeps its nullgetAssignedCondition(), so callers that detect "no experiment" with a null check keep working. Making it an empty Condition to match JS was tried and reverted as a breaking change. Assignment gains null-safe getCondition()/getPayload(), an assignment with an empty condition list no longer throws, and the docs now say the condition is null for the placeholder (and that the Assignment itself is never null). The experiment type stays null as Java's "no experiment" signal; JS reports SIMPLE, and Python returns None.
Java auth token is optional, like JS and Python. The Authorization header is only sent when there's a token; servers with client auth enabled (toCheckAuth) answer a request without one with a 401.
Marking an Assignment uses the ids it was created with, in all three libs. JS's Assignment used to look up the experimentId and condition id in the cache at mark time. With this PR's cache clearing, marking an Assignment held from before a group or alias change could send no experimentId, or another experiment's ids if the cache had been refilled with a different experiment at the same decision point. It now sends its own, as Java's (since Phase A) and Python's already do. Client-level marks, which have no Assignment, still fill in ids from the cache in all three.
READMEs recommend applying the payload and marking through the assignment. The payload defaults to the condition code, so this keeps working when custom payloads are added. They also warn that a client-level mark must pass the condition code: for Thompson Sampling (and group-assigned stratified random sampling) the server enrolls the user in the condition whose code matches what's sent, so a payload value silently skips enrollment.
Documented rather than changed:markDecisionPoint argument order (changing it would silently swap string arguments for existing callers), and the timeout/retry differences, now described in each README (the comparison is in clientlibs/TIMEOUT_RETRY_POLICY.md). This also closes the JS quick start's code block, which was never closed.
Phase D: guardrails
Java request-serialization tests for every endpoint.ExperimentClientTest checks the HTTP method and request body of /init (all three overloads), /groupmembership, /workinggroup, /useraliases, /assign, /mark, /log, /featureflag and /reward against what the backend's validators expect, plus the standard client headers. /init is checked for its exact field set, since the backend rejects unknown fields there. (The /groupmembership and /workinggroup beans also send nullcreatedAt/updatedAt/versionNumber/requestedUserId, which the server strips.)
Client API coverage checklist.clientlibs/CLIENT_API_COVERAGE.md records which method and test implement each v6 client endpoint in each library, with a workflow: mark libraries ❌ when an endpoint changes, flip them back to ✅ when implemented, and review before a client-library release. It deliberately doesn't restate the contract (the controller and its validators are the source of truth; swagger may lag), apart from two behaviors the libraries depend on: /init rejects unknown body fields, and /mark must get the condition code. The root CLAUDE.md points to it.
Testing
JS:jest: 113/113 pass, ESLint/Prettier clean.
Python:pytest: 240/240 pass; ruff check and mypy clean.
Java:mvn test passes at the branch head (ExperimentClientTest, 32 tests). Also 32/32 from a mvn clean build run directly via TestNG, 5 runs in a row.
Merging with dev: a trial merge of current dev into this branch is clean (no conflicts), and all three libraries build and pass their tests on the merged code, including dev's new Java QuickTestBurst* files.
⚠️ The first commit (2bf0337) has a Java compile error (a stray brace); it's fixed in b19d284. Pre-commit hooks don't build the client libs, so review the Java at the branch head.
Not in this PR
Configurable HTTP behavior (retries, timeouts) for the JS default client: a follow-up.
Automated contract tests against the backend: not pursued; the coverage checklist covers the gap for now.
Small cleanups:clientlibs/js/docs (TypeDoc output) is stale and needs regenerating; the Java README still documents several methods that no longer exist; Python's __version__ says 6.5.0; Python feature flags come back as FeatureFlag objects rather than strings; Java's group requests send harmless null fields.
Adds clientlibs/CLIENT_LIB_PARITY_AUDIT.md, an audit of the JS, Java and
Python client libs against each other and the backend /v6 API, with a
phased reconciliation plan. Implements Phase A: fixes that need no API
or product decisions.
Java:
- markDecisionPoint sends the cached assignment's experimentId and
condition id (all overloads, plus Assignment.markDecisionPoint), so the
server marks the experiment the user was actually assigned instead of
re-selecting one
- rotate assignedFactor alongside assignedCondition on within-subjects marks
- populate assignment/feature-flag caches before invoking onSuccess, so
calls chained from inside the callback see a warm cache
- null-safe callbacks and properties map; null-safe target lookup
- first TestNG suite (src/test/java) against an in-process HttpServer
JS:
- DefaultHttpClient: retries never ran (axios rejects non-2xx before the
retry branch); now retries network errors and 5xx with backoff, fails
fast on 4xx, and sets a 5s default timeout
- ISendRewardResponse gains mode/success
- markDecisionPoint no longer throws when the assignment cache is null
- fix 3 pre-existing ApiService mark tests missing the context field
Python:
- _sync methods call ApiService's sync implementations instead of
asyncio.run(), so they work inside a running event loop
- reuse pooled httpx clients per UpgradeClient; add close()/aclose()
and sync/async context manager support
Docs: root CLAUDE.md lists the python lib (not a nonexistent csharp
lib); java/CLAUDE.md documents tests and configurable timeouts.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Java rewards: sendRewardForExperiment / sendRewardForDecisionPoint,
explicit context (matching JS and Python), boolean awaitResult overload
- Feature-flag session groups: setter in Java and Python, plus a
constructor kwarg in Python only
- Dead endpoint methods to be deleted with no version bump
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Fix: the Phase A Java cache-timing change left a stray closing brace in
ExperimentClient.java, so the Java lib did not compile at 2bf0337 (the
tests that "passed" had run against stale class files). All Java results
here come from a clean build.
Java:
- sendRewardForExperiment / sendRewardForDecisionPoint, each with a
boolean awaitResult overload; context is explicit (matching JS and
Python); new RewardRequest, DecisionPoint, SendRewardResponse beans and
BinaryRewardValue enum
- setFeatureFlagUserGroupsForSession / clearFeatureFlagUserGroupsForSession;
feature flag requests use a FeatureFlagRequest bean that omits the
session-group fields when unset
- APIService.prepareRequest gains a query-param overload
- remove addGroupMetrics / addSingleMetrics (POST /v6/metric never existed
as a client route) and the GroupMetric, SingleMetric, MetricUnitBody
beans; Metric and MetricMetaData stay because LogEventResponse uses them
- tests for rewards (body shape, awaitResult query, 409 handling, argument
validation) and session groups (body shape, cache invalidation)
JS:
- sendReward accepts awaitResult, sent as ?awaitResult=true; params typed
as ISendRewardParams
- remove logCaliper (POST /v6/log/caliper was removed in #1985)
- sendReward tests
Python:
- feature_flag_user_groups_for_session constructor kwarg and
set_feature_flag_user_groups_for_session setter, taking a
FeatureFlagUserGroupsForSession model or dict; both fields required;
changing it clears the flag cache
Docs: Java/Python READMEs, Java/JS CLAUDE.md, audit marked Phase B done.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
bcb37
changed the title
Client lib parity audit
Client lib parity: audit, Phase A fixes, Phase B feature parity
Oct 28, 2026
…sync pool
Retries (JS and Java): only reads and idempotent upserts are retried
(/init, /groupmembership, /workinggroup, /useraliases, /assign,
/featureflag). /mark, /log and /reward are never retried: a timeout or 5xx
doesn't mean the write wasn't applied, and none are deduplicated
server-side. A resent /reward double-counts the observation and biases
adaptive assignment.
- JS: ApiService marks safe requests with an optional `retryable` flag on
IHttpClientWrapperRequestConfig; DefaultHttpClient retries only those
- Java: /mark, /log and /reward use NO_RETRIES (Java previously retried
/mark and /log once, and Phase B's reward did too)
Python async pooling: an AsyncClient must be closed on the loop that
opened it, so successive asyncio.run() calls stranded one pool per loop.
The async pool now exists only inside `async with client:`; outside it,
each async request uses a short-lived client (dev's behavior). The sync
pool is unchanged. Tests verify every client created across separate
asyncio.run() loops is closed.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The reason will be displayed to describe this comment to others. Learn more.
🟡 Changes recommended
Both Python and Java clients allow in-flight feature-flag responses to repopulate invalidated caches after session groups change, causing subsequent lookups to keep returning flags evaluated for the old groups.
Address Copilot review: if the session groups were changed or cleared
while a feature-flag request was in flight, its response was cached when
it landed, and later lookups kept returning flags evaluated for the old
groups. Same bug in all three libs (pre-existing in JS; from Phase B in
Java and Python).
Changing the groups (and, in Python, init()) now bumps a generation
counter; a response is only cached if the generation it was requested
under is still current. The caller still receives the response.
- Java: generation, cache and groups guarded by one lock, since responses
arrive on Jersey threads
- Python: generation check and cache write are atomic under a lock, for
_sync callers on multiple threads
- JS/Python hasFeatureFlag check the flags that call fetched rather than
the possibly-invalidated cache (Java already did)
Each lib gets a delayed-response regression test; each fails on the
previous code.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The reason will be displayed to describe this comment to others. Learn more.
🔵 Needs a closer look
Unresolved thread-safety issues can leak HTTP client pools during lazy initialization and cause feature-flag lookups to raise AttributeError when session groups are cleared concurrently.
No open issues 🎉
2 previously missed issues
In code that hasn't changed since last review
Protect lazy HTTP client initialization from concurrent access
Two threads making their first _sync call can both pass this check and construct separate httpx.Client instances. One assignment can overwrite the other pool, leaving it unavailable to close() or aclose() for cleanup. Protect lazy initialization with a per-instance lock and coordinate close() with the same lock.
Use a stable feature-flag options snapshot during request construction
A synchronous feature-flag lookup can fail if another thread clears the session groups between this check and model_dump(): the second attribute read sees None and raises AttributeError before sending the request. The cache-generation lock does not protect request-body construction. Capture the options in a local variable and use that same snapshot for both the check and serialization.
The server's client-path decision point lookups are case-sensitive
(/mark matches `partitions.site = :site AND partitions.target = :target`,
then re-checks with ===), so the client libs now match the same way.
- Java: findExperimentResponse uses equals instead of equalsIgnoreCase.
Its case-insensitive lookup returned conditions for a differently-cased
site/target, and the mark sent with the caller's casing couldn't be
attributed to the experiment server-side.
- Python: an explicit None target now matches "" (as JS and Java do), and
/mark sends "" rather than null.
- JS: unchanged (already exact); tests added.
Audit doc: records this decision, and defers the "no experiment at
decision point" item with notes on current behavior.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Assignments and feature flags both depend on the user's groups, but no
lib invalidated its caches when they changed (only Python's init()
cleared them, and before the request). Now, in all three libs, init()
(always), setGroupMembership and setWorkingGroup clear both caches once
the server confirms the call; a failed call leaves them alone.
setAltUserIds doesn't clear them, since aliases don't change groups.
- The assignment cache gets the same generation guard as feature flags,
so an /assign that was in flight during a group change isn't cached
over the clear (the caller still receives it). Java guards it with a
lock; Python's check-and-write is atomic under a lock.
- JS/Python getDecisionPointAssignment look up in the list they just
fetched rather than the possibly-cleared cache.
- Side effect: within-subjects condition rotation restarts after a group
change, since the refetched list comes back in the server's order.
Tests in each lib cover each clearing call, the failed-call and alias
cases, and the in-flight race. Java's test server now handles requests
concurrently so the race test exercises the guard (it fails with the
guard disabled). The JS cache spec is renamed to UpgradeClient.cache.spec.ts.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Documents how JS, Java and Python differ in default timeout, what the
timeout limits, configurability and retry count/backoff, the resulting
worst-case durations, and options for making them consistent (Phase C).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Decided to document rather than change the libs' differing argument
order: changing either would silently swap values for existing callers,
since the swapped arguments are all strings.
- Java: Javadoc on ExperimentClient/Assignment markDecisionPoint notes
clientError comes before uniquifier, unlike JS and Python. The README's
stale markExperimentPoint section is replaced with markDecisionPoint,
including a cross-library signature table.
- Python README: notes condition/status come first and recommends keyword
arguments.
- JS README: points to the options-object form. Also fixes the quick
start, which marked with the payload value instead of getCondition().
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The constructor threw on a null or empty token, so Java users had to pass
a placeholder even against servers that don't check client auth. Now,
like JS and Python, the token is optional and the Authorization header is
only sent when there is one. Whether a token is required is a server
setting (toCheckAuth, enforced in ClientLibMiddleware), so a missing
token surfaces as a 401 from servers that check.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
bcb37
changed the title
Client lib parity: audit, Phase A fixes, Phase B feature parity
Client lib parity: audit, Phase A fixes, Phase B feature parity, Phase C alignment
Oct 29, 2026
@copilot can you turn this PR summary into a code-review requirement / QA checklist, organized by client library, that I can check off one by one online here in the github UI? refer to the phased planning docs within the files being added. This should be concatenated to the PR description (don't overwrite the current summary).
@copilot can you edit this checklist based on the changes in the most recent commit?
@copilot can you turn this PR summary into a code-review requirement / QA checklist, organized by client library, that I can check off one by one online here in the github UI? refer to the phased plan...
I can’t directly edit the PR description with the GitHub operations available in this session. Here is an append-ready checklist for the description; it follows the phased plan in the parity audit, §5 and the timeout/retry policy.
Code-review / QA checklist
Java client
Verify all markDecisionPoint overloads, including Assignment.markDecisionPoint(), send the cached experiment ID and condition ID.
Verify within-subjects marks rotate both the assigned condition and factor.
Verify assignment and feature-flag caches are populated before success callbacks; null callbacks, properties, and target lookups are safe.
Verify reward methods cover both experiment-ID and decision-point lookup forms, require explicit identifiers/context, default awaitResult to false, and report awaited 409 responses through onError.
Verify session feature-flag groups can be set/cleared, invalidate the flag cache, and prevent an in-flight response for old groups from repopulating it.
Verify decision-point matching is exact; successful init, group-membership, and working-group changes invalidate the relevant caches, while alias changes do not.
Verify a missing auth token omits the Authorization header, and retries are limited to safe requests (never /mark, /log, or /reward).
Verify removed metric request methods are absent while response types still used by /log remain.
Run mvn test at the PR head; confirm request serialization coverage for the client endpoints.
JavaScript client
Verify the default HTTP client retries only network failures and 5xx for requests marked retryable, fails fast on 4xx, and applies the documented default timeout.
Verify /mark, /log, and /reward are never retried; custom/lite HTTP clients can honor the optional retryable flag.
Verify reward responses include mode/success and sendReward forwards awaitResult.
Verify markDecisionPoint handles a missing assignment cache and sends the expected request context.
Verify changing/clearing feature-flag session groups invalidates the cache and an old in-flight response is not cached.
Verify successful init, group-membership, and working-group changes invalidate assignment and flag caches, with a generation guard against stale assignment responses; alias changes do not invalidate them.
Verify the removed logCaliper method is absent.
Run Jest, ESLint, and Prettier; confirm the documented test suite passes.
Python client
Verify sync methods use the real synchronous API implementations and work when called from a running event loop.
Verify sync pooling and async context-managed pooling close their clients; requests outside async with do not reuse a pool across event loops.
Verify session feature-flag groups accept the model/dict forms, require both fields, invalidate the cache, and use generation/locking so stale in-flight responses are not cached.
Verify successful init, group-membership, and working-group changes invalidate assignment and flag caches; alias changes do not. Confirm stale /assign responses cannot refill the cache.
Verify decision-point matching is exact and None target is treated as an empty string.
Run pytest, Ruff, and mypy; confirm the documented test suite passes.
Cross-client / scope checks
Verify the retry allowlist is consistent with the audit: /init, /groupmembership, /workinggroup, /useraliases, /assign, and /featureflag may retry; /mark, /log, and /reward may not.
Confirm the timeout/retry alignment decision and the no-experiment-at-decision-point behavior remain explicitly deferred as described in the planning docs.
Confirm Phase D guardrails (shared endpoint contract checklist/tests and Java/Python CI coverage) are identified as follow-up work, not represented as completed in this PR.
Confirm the PR remains limited to client libraries and existing /v6 APIs; no backend behavior or endpoint contract changed.
On dev, DefaultHttpClient's retries never ran, because axios throws on
non-2xx before the retry check. Phase A fixed that, which turned on the
existing default of 3 retries with 300/600/1200 ms backoff: up to ~22s
for a failing request, with no way for callers to turn it off. Per
review, the default is now 1 retry after a 300 ms wait (~10s worst
case), as DEFAULT_MAX_RETRIES / DEFAULT_RETRY_BACKOFF_MS constants.
Making retries configurable is left for a follow-up.
Docs updated: JS README, js/CLAUDE.md, the Java and Python READMEs'
cross-library notes, and TIMEOUT_RETRY_POLICY.md.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
clientlibs/CLIENT_API_COVERAGE.md records which method and test implement
each /api/v6 client endpoint in the JS, Java and Python client libraries,
with a workflow for keeping it current: mark libraries ❌ when an endpoint
changes, flip them back to ✅ when implemented, and review before a
client-library release.
It deliberately doesn't restate the contract. The controller and its
validators are the source of truth; swagger is a hand-written reference
that may lag. The only API notes it carries are two behaviors the
libraries depend on that aren't obvious from the validators: /init
rejects unknown body fields, and /mark must get the condition code (not a
payload value) for Thompson Sampling and group-assigned stratified
random sampling.
The root CLAUDE.md points to it. The audit marks Phase D complete.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
bcb37
changed the title
Client lib parity: audit, Phases A–C, and Phase D Java tests
Client lib parity: audit and Phases A–D
Nov 6, 2026
The reason will be displayed to describe this comment to others. Learn more.
🟡 Changes recommended
Critical issues remain: cache invalidation can misattribute JavaScript assignment marks to the wrong experiment, and unsynchronized Java assignment mutations can pair a condition with another treatment’s factors.
The reason will be displayed to describe this comment to others. Learn more.
@copilot explain this potential issue via a sequence of events that would make this come up? we should not bending over backwards to guard against the entire universe of pointless/thoughtless usage patterns from the library user, that is a recipe for bloating our code and causing bugs due to undue complexity.
The reason will be displayed to describe this comment to others. Learn more.
One concrete sequence is: the client fetches assignment A for a decision point and returns an Assignment; the app applies A; a successful init() or group update then clears the assignment cache; the app later calls markDecisionPoint() on that already-held Assignment. Assignment retains site/target/condition code but not the experiment/condition IDs, and the mark path looks those IDs up in the now-empty cache, so the server can re-select instead of attributing the mark to A. If another assignment has refilled the cache by then, it could attach that experiment’s IDs instead.
This doesn’t require an in-flight response race or failing to await the update—it’s an old Assignment object used after user state changed. I agree a generation guard would be disproportionate; the narrower question is whether we want to support marking an assignment object after such a change. If not, callers can fetch and mark before changing user/group state, then fetch a fresh assignment afterward. I haven’t changed code based on this question.
The reason will be displayed to describe this comment to others. Learn more.
Fixed in 5c586c1. A JS Assignment now keeps the experimentId and condition id it was created with, and passes them to ApiService.markDecisionPoint(), which uses them instead of looking them up in the cache. So marking an Assignment held from before init() or a group/alias change records the experiment the user was actually shown. This brings JS in line with Java and Python, whose Assignment marks already send their own snapshot's ids. On dev the JS cache was never cleared, so the lookup rarely mattered; this PR's cache clearing is what made it matter. Client-level marks (site, target and condition code, no Assignment) still fill in the ids from the cache, as in the other two libraries. The new test in UpgradeClient.cache.spec.ts covers your sequence (A shown, groups change, B refetched at the same decision point, A marked); it fails against the previous code.
@copilot can you edit the checklist you created (or append an updated one) based on the changes from the most recent commit?
I can’t edit the PR description directly with the available GitHub operations. Here’s an updated append-ready checklist for the description; the Java smoke-test item reflects the latest fd234c6 commit. It follows the phased parity audit (§5) and the timeout/retry policy.
Code-review / QA checklist
Java client
Verify markDecisionPoint overloads and Assignment.markDecisionPoint() send the cached experiment ID and condition ID; verify within-subjects marks rotate both condition and factor.
Verify assignment and feature-flag caches are populated before success callbacks; callback/property/target null cases are safe.
Verify successful init, group-membership, working-group, and alias updates invalidate assignment and flag caches, and failed updates do not.
Verify exact site/target matching; no-experiment results retain the documented null condition/type behavior, while getCondition()/getPayload() are safe.
Verify reward methods support experiment-ID and decision-point lookup, require the relevant identifiers/context, default awaitResult to false, and route awaited 409s to onError.
Verify feature-flag session groups can be set/cleared, are sent in the request, and invalidate cached flags.
Verify auth is omitted without a token and retries are limited to safe requests—not /mark, /log, or /reward.
Verify removed metric request methods are absent while /log response beans remain.
Run mvn test and review request-serialization coverage. For a live-server smoke test, verify the updated QuickTest exercises both feature-flag session-group modes and both reward lookup forms, queued and awaited.
JavaScript client
Verify default HTTP retries handle network failures/5xx once after the configured default backoff, fail fast on 4xx, and use the default timeout; confirm only retry-safe requests are marked retryable.
Verify sendReward supports awaitResult and its response type includes mode and success.
Verify markDecisionPoint is safe after a failed /assign, sends context and the condition code, and uses cached assignment metadata when available.
Verify successful user/group updates and changes to session feature-flag groups clear the relevant caches; failed updates leave caches intact.
Verify feature-flag session-group options/setter require both fields together and session-group changes invalidate cached flags.
Verify exact decision-point matching and removal of the dead logCaliper endpoint method.
Run the JS Jest suite and the existing ESLint/Prettier checks.
Python client
Verify sync methods use the sync ApiService implementations and work when called from an already-running event loop.
Verify sync connection pooling and close()/context-manager support; async pooling is scoped to async with, with aclose() support.
Verify reward await_result behavior and feature-flag session groups via constructor and setter (model and dict forms), including cache invalidation.
Verify successful user/group updates clear relevant caches, exact site/target matching treats None target as "", and Assignment.mark_decision_point() rotates within-subjects assignments.
Verify Python does not retry requests, as documented.
Run pytest, Ruff, and mypy.
Cross-library / release checks
Review CLIENT_API_COVERAGE.md against the backend v6 controller/validators; confirm each endpoint is ✅ or intentionally ➖.
Review each README’s caching and mark/payload guidance, plus the timeout/retry policy; confirm docs match implemented behavior.
Before release, resolve or explicitly defer every ❌ in the coverage checklist.
… a lock
JS: an Assignment now marks with the experimentId and condition id it was
created with, instead of looking them up in the cache at mark time. This
brings JS in line with Java (since Phase A) and Python, whose Assignment
marks already send their own snapshot's ids. On dev, JS's cache lookup
rarely mattered because the cache was never cleared; with this PR's
clearing on init/group/alias changes, marking an Assignment held from
before the change could send no experimentId (cache still empty) or
another experiment's ids (cache refilled with a different experiment at
the same decision point). Client-level marks, which have no Assignment,
still fill in ids from the cache in all three libraries. Adds `id` to the
/mark request-body type, which was always sent.
Java: rotateConditions rotates conditions and factors as one step under
assignmentLock, and findExperimentResponse copies an assignment out of
the cache under the same lock, so a lookup never sees conditions rotated
without their factors.
Both from Copilot review.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The reason will be displayed to describe this comment to others. Learn more.
🔵 Needs a closer look
Human assessment is needed because the Python, JavaScript, and Java examples mishandle valid assignments without payloads while still marking conditions as applied.
…tatus
From Copilot review: the examples read only the payload, so a valid
assignment without one applied nothing yet was still marked
CONDITION_APPLIED. Default payloads (the condition code) are only created
when an experiment is updated, not when it's created, so an experiment
created through the API without payloads and never edited has none.
All three READMEs still apply the payload first, but fall back to the
condition code when there isn't one. JS and Java, whose "no experiment"
placeholder has neither, now mark NO_CONDITION_ASSIGNED in that case
instead of CONDITION_APPLIED. Python returns None rather than a
placeholder, so its examples only needed the fallback.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Re: the latest Copilot review ("the Python, JavaScript, and Java examples mishandle valid assignments without payloads while still marking conditions as applied"): fixed in 4a58f1d.
Valid assignments without a payload. The default payload (the condition code) is only created when an experiment is updated, not when it's created, so an experiment created through the API without payloads and never edited has none, and /assign returns payload: null. All three READMEs still apply the payload first, as we recommend, but now fall back to the condition code when there isn't one (getPayload()?.value ?? getCondition() in JS, and the equivalent in Java and Python).
Marking. The JS and Java examples now mark NO_CONDITION_ASSIGNED when there's no condition at all (the "no experiment here" placeholder), instead of CONDITION_APPLIED. Python returns None rather than a placeholder and only marks inside if assignment:, so it only needed the fallback.
Whether the backend should always create default payloads (at experiment creation, or as a fallback in the /assign response) is a separate question for a backend PR; the README fallback is still worth having either way, since the client libraries can talk to servers without such a change.
…esponse
Brings in client lib parity (#3408). Conflict resolution in the Java
client's assignment cache:
- fullContextCached is guarded by assignmentLock together with
allExperiments (was a volatile flag plus synchronized methods)
- invalidateUserDependentCaches() also resets fullContextCached, so a
group or identity change refetches instead of returning null
- isAtDecisionPoint() uses #3408's exact, null-safe match, for cache
hits, lookups and mark metadata alike
- getAllExperimentConditions() goes through requestAssignments(),
keeping #3408's null-safe callbacks
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…load
requestAssignments() now uses the null-safe Consumer variant from #3408,
so this branch's UnaryOperator variant has no callers.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Drops ExperimentRequest's inner DecisionPoint in favor of the top-level
bean #3408 added for rewards. A null target is now sent as "" (as the
reward request does) instead of being omitted; the server treats both
as the targetless decision point.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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
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.
Summary
The JS, Java, and Python client libs have drifted apart. This PR adds
clientlibs/CLIENT_LIB_PARITY_AUDIT.md, an audit of all three against each other and the backend/v6API with a phased reconciliation plan, and implements Phase A (correctness fixes) and Phase B (feature parity). No backend changes; everything uses the existing/v6API.Phase A: correctness fixes
Java
markDecisionPointnow sendsexperimentIdand conditionidfrom the cached assignment, across all overloads andAssignment.markDecisionPoint(). Previously Java sent onlyconditionCode, so the server re-ran experiment selection and could mark a different experiment when several share a decision point.assignedFactoralongsideassignedCondition.onSuccessruns. Previously a call chained from inside the callback (e.g. a mark) could hit a cold cache.propertiesmap, null-safe target lookup.src/test/java, TestNG). It runsExperimentClientagainst an in-processHttpServer.JS
DefaultHttpClientnever retried: axios rejects non-2xx responses before the retry branch runs, and that branch would have retried 4xx rather than 5xx anyway. It now retries network errors and 5xx once, after a 300 ms wait, for idempotent requests only (see Retry policy), fails fast on 4xx, and sets a 5s default timeout. Because the retry code never ran before, turning on the old default of 3 retries would have meant up to ~22s of retrying that callers can't turn off, so the default is 1 (DEFAULT_MAX_RETRIES/DEFAULT_RETRY_BACKOFF_MS). Making retries configurable is left for a follow-up.ISendRewardResponsegainsmode/successto match the backend.markDecisionPointno longer throws when the assignment cache is null (e.g. after a failed/assign).ApiServicemark tests that already fail ondev: their expected bodies were missingcontext.Python
_syncmethods useApiService's real sync implementations instead ofasyncio.run(), so they work inside a running event loop (Jupyter, async frameworks).close()/aclose()andwith/async withsupport.Phase B: feature parity
Rewards
sendRewardForExperiment(value, experimentId, [awaitResult], cb)andsendRewardForDecisionPoint(value, context, site, target, [awaitResult], cb). Named methods mean a caller can't pass neither identifier.contextis explicit, matching JS and Python. A 409 (withawaitResult) is reported toonErrorand not retried.sendRewardacceptsawaitResult, sent as?awaitResult=true. Python already had it.Feature-flag session groups (
groupsForSession+includeStoredUserGroups), previously JS-onlysetFeatureFlagUserGroupsForSession(groups, includeStored)/clearFeatureFlagUserGroupsForSession(). Setter-only, so the constructor overloads don't double.feature_flag_user_groups_for_session=constructor kwarg plusset_feature_flag_user_groups_for_session(). Both accept aFeatureFlagUserGroupsForSessionmodel or a dict, and both fields are required.Dead endpoint methods removed
These were breaking removals, but every call already failed with a 404.
logCaliper():POST /v6/log/caliperwas removed in remove all client API code for versions < 5 #1985.addGroupMetrics()/addSingleMetrics():POST /v6/metricwas never a client route. MATHia doesn't use them. TheMetricbean stays because the/logresponse uses it.Retry policy
Retries are limited to requests that are safe to repeat, in both JS and Java:
/init,/groupmembership,/workinggroup,/useraliases(upserts),/assign(read-only), and/featureflag(records exposures if-not-exists)./mark,/logand/reward. None of them are deduplicated server-side, so resending after a lost response would record the write twice, and a duplicated reward biases adaptive assignment.JS marks safe requests with an optional
retryableflag on the request config, which custom lite-build HTTP clients can also honor. This changes Java's existing behavior: it previously retried/markand/logonce. Python never retries.Python connection pooling
The sync client is pooled per
UpgradeClient. The async client is pooled only insideasync with client:, because anAsyncClientmust be closed on the loop that opened it. Outside that block each async request uses a short-lived client, same asdev, so successiveasyncio.run()calls don't strand connection pools.Phase C: aligning behavior
/markand/rewardlookups are case-sensitive. Java matched case-insensitively, so it returned conditions whose marks the server couldn't attribute to the experiment. Python now treats aNonetarget as"".init(),setGroupMembership,setWorkingGroupandsetAltUserIdsclear the assignment and feature-flag caches once the server confirms the call. Previously only Python'sinit()cleared anything. Clearing is best-effort: a lookup that was already in flight can still cache a response from before the change. That leaves the cache exactly as stale asdevalways did, so it's still a strict improvement, and each README's new "Caching" section tells callers to await these calls before reading, or passignoreCache. (A generation-counter guard against that race was built, then removed after review as over-engineering.) Java reads and writes both caches under a lock, since they're written on Jersey threads anddevhad no synchronization there; within-subjects rotation and the lookups that copy an assignment out of the cache hold the same lock, so a lookup never sees conditions rotated without their factors. One side effect: within-subjects rotation restarts after one of these calls.Assignment.mark_decision_point()now rotates within-subjects assignments, like the client-level mark and like JS.nullgetAssignedCondition(), so callers that detect "no experiment" with a null check keep working. Making it an emptyConditionto match JS was tried and reverted as a breaking change.Assignmentgains null-safegetCondition()/getPayload(), an assignment with an empty condition list no longer throws, and the docs now say the condition isnullfor the placeholder (and that theAssignmentitself is nevernull). The experiment type staysnullas Java's "no experiment" signal; JS reportsSIMPLE, and Python returnsNone.Authorizationheader is only sent when there's a token; servers with client auth enabled (toCheckAuth) answer a request without one with a 401.Assignmentuses the ids it was created with, in all three libs. JS'sAssignmentused to look up theexperimentIdand conditionidin the cache at mark time. With this PR's cache clearing, marking anAssignmentheld from before a group or alias change could send noexperimentId, or another experiment's ids if the cache had been refilled with a different experiment at the same decision point. It now sends its own, as Java's (since Phase A) and Python's already do. Client-level marks, which have noAssignment, still fill in ids from the cache in all three.markDecisionPointargument order (changing it would silently swap string arguments for existing callers), and the timeout/retry differences, now described in each README (the comparison is inclientlibs/TIMEOUT_RETRY_POLICY.md). This also closes the JS quick start's code block, which was never closed.Phase D: guardrails
ExperimentClientTestchecks the HTTP method and request body of/init(all three overloads),/groupmembership,/workinggroup,/useraliases,/assign,/mark,/log,/featureflagand/rewardagainst what the backend's validators expect, plus the standard client headers./initis checked for its exact field set, since the backend rejects unknown fields there. (The/groupmembershipand/workinggroupbeans also sendnullcreatedAt/updatedAt/versionNumber/requestedUserId, which the server strips.)clientlibs/CLIENT_API_COVERAGE.mdrecords which method and test implement each v6 client endpoint in each library, with a workflow: mark libraries ❌ when an endpoint changes, flip them back to ✅ when implemented, and review before a client-library release. It deliberately doesn't restate the contract (the controller and its validators are the source of truth; swagger may lag), apart from two behaviors the libraries depend on:/initrejects unknown body fields, and/markmust get the condition code. The rootCLAUDE.mdpoints to it.Testing
jest: 113/113 pass, ESLint/Prettier clean.pytest: 240/240 pass;ruff checkandmypyclean.mvn testpasses at the branch head (ExperimentClientTest, 32 tests). Also 32/32 from amvn cleanbuild run directly via TestNG, 5 runs in a row.dev: a trial merge of currentdevinto this branch is clean (no conflicts), and all three libraries build and pass their tests on the merged code, includingdev's new JavaQuickTestBurst*files.2bf0337) has a Java compile error (a stray brace); it's fixed inb19d284. Pre-commit hooks don't build the client libs, so review the Java at the branch head.Not in this PR
clientlibs/js/docs(TypeDoc output) is stale and needs regenerating; the Java README still documents several methods that no longer exist; Python's__version__says 6.5.0; Python feature flags come back asFeatureFlagobjects rather than strings; Java's group requests send harmlessnullfields.🤖 Generated with Claude Code