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. 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.
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: 110/110 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.
⚠️ 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.
… docs
- Java: when no experiment is assigned at a decision point, the
placeholder Assignment's getAssignedCondition() is an empty Condition
instead of null (also in getAllExperimentConditions(site, target)), so
chaining getConditionCode() no longer throws. Assignment gains
getCondition()/getPayload(). The experiment type stays null, Java's
explicit "no experiment" signal (JS keeps reporting SIMPLE); the
Javadoc warns that switching on it throws. Also guards an assignment
with an empty condition list.
- All libs: setAltUserIds now clears the assignment and feature-flag
caches once the server confirms, like init/setGroupMembership/
setWorkingGroup. The shared helper is renamed to "user-dependent".
- Python: Assignment.mark_decision_point (async and sync) now rotates the
cached within-subjects assignments, via an on_marked callback wired to
the client's own rotation, matching JS.
- Timeouts and retries: left as they are, by decision, and documented in
each README and in js/java CLAUDE.md; TIMEOUT_RETRY_POLICY.md records
the decision. The Java README's stale getExperimentCondition section is
replaced.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@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 directly edit the PR description with the available GitHub operations, but here is a corrected, append-ready checklist. Use this version in place of the earlier checklist; it reflects the latest commit and the decisions in the phased audit and timeout/retry policy.
Code-review / QA checklist
Java client
Verify markDecisionPoint overloads, including Assignment.markDecisionPoint(), send the cached experiment and condition IDs; within-subjects marks rotate the assigned condition and factor.
Verify assignment and feature-flag caches are populated before success callbacks, and null callbacks, properties, and target lookups are safe.
Verify reward methods support experiment-ID and decision-point forms, require the relevant 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, working-group, and alt-user-ID calls clear assignment and flag caches; failed calls leave caches intact.
Verify no-experiment results use an empty Condition (including the site/target lookup), getCondition()/getPayload() are safe, and the experiment type remains null as documented.
Verify a missing auth token omits the Authorization header; safe requests alone are retried, never /mark, /log, or /reward.
Verify removed metric request methods are absent while response types used by /log remain.
Run mvn test at the PR head and verify 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 uses the documented default timeout; /mark, /log, and /reward are never retried.
Verify reward responses include mode/success and sendReward forwards awaitResult; markDecisionPoint safely handles a missing assignment cache.
Verify changing/clearing feature-flag session groups invalidates the cache and an old in-flight response is not cached.
Verify successful init, group-membership, working-group, and alt-user-ID calls clear assignment and flag caches; failures do not. Stale in-flight assignment responses must not refill a cleared cache.
Verify the removed logCaliper method is absent.
Verify the README documents the chosen timeout/retry policy: keep the current per-library differences rather than standardize them.
Run Jest, ESLint, and Prettier.
Python client
Verify sync methods use synchronous API implementations and work when called from a running event loop; sync pooling and async context-managed pooling close clients correctly without reusing pools across event loops.
Verify session feature-flag groups accept model/dict forms, require both fields, invalidate the cache, and prevent stale in-flight responses from being cached.
Verify successful init, group-membership, working-group, and alt-user-ID calls clear assignment and flag caches; failures do not, and stale assignment responses cannot refill a cleared cache.
Verify decision-point matching is exact, None target is treated as an empty string, and Assignment.mark_decision_point() rotates within-subjects assignments in both async and sync paths.
Verify the README documents the chosen timeout/retry policy and run pytest, Ruff, and mypy.
Cross-client / follow-up scope
Verify the retry allowlist is consistent: /init, /groupmembership, /workinggroup, /useraliases, /assign, and /featureflag may retry; /mark, /log, and /reward may not.
Verify Phase D guardrails (shared endpoint contract checklist/tests and Java/Python CI coverage) remain identified as follow-up work, not completed functionality.
Confirm the PR remains limited to client libraries and existing /v6 APIs; no backend behavior or endpoint contract changed.
The latest commit adds the Java placeholder behavior, Python assignment-level within-subjects rotation, and cache invalidation after setAltUserIds in all three libraries; those items are included above. The timeout/retry decision is now documented as keeping the current differences. The latest workflow is still in progress, so I can’t confirm a full test run at this head yet; the previously listed PR checks passed before this commit.
The reason will be displayed to describe this comment to others. Learn more.
I wouldn't make this update, we have always recommended using payload by default, as using payloads let's the implementing code be more experiment agnostic... i.e. a decision-point set up to only look for the condition itself will need updated later the moment someone wants to use payloads in an experiment, whereas if they just used the payload in the first place, they always get the correct thing, regardless of whether they use custom payloads or not.
The reason will be displayed to describe this comment to others. Learn more.
I'd forgotten that. But there's still an issue with the README because it then includes client.markDecisionPoint(decisionPoint.site, decisionPoint.target, condition, MARKED_DECISION_POINT_STATUS.CONDITION_APPLIED);
Sending the payload value as condition to the mark method. In a TS experiment, if there are custom payloads, this will not enroll the student, even if they got an assignment. I can change it so that the mark method is called on the assignment, rather than the client.
UpGrade's long-standing guidance is to apply a condition's payload rather
than its code: the payload defaults to the condition code (ExperimentService
seeds one per condition and decision point), so code written that way keeps
working when someone later adds custom payloads. An earlier commit on this
branch switched the JS quick start to getCondition(), reversing that.
All three READMEs now apply the payload (null-safe, since the "no
experiment" placeholder has none) and mark through the assignment, which
always sends the condition code. They also warn that client-level marks
must pass the condition code, not the payload value: 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 and the user's rewards can't be recorded.
Also closes the JS quick start's code block, which was never closed, so
everything after it rendered as code.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Making the "no experiment here" placeholder's getAssignedCondition() an
empty Condition (to match JS) silently broke callers that detect "no
experiment" by checking it for null, so it's reverted:
getAssignedCondition() is null for the placeholder again, and
getAllExperimentConditions(site, target) again returns a blank response.
Kept, since they're additive: Assignment's null-safe getCondition() /
getPayload(), the guard against an assignment with an empty condition
list, and the null experiment type as the "no experiment" signal. The
Javadoc, README and java/CLAUDE.md now say the condition is null for the
placeholder, and that the Assignment itself is never null, so checking it
doesn't prevent an NPE when chaining. QuickTest is back to its original
null check.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Per review, the generation-counter guard (which kept a response that was
in flight when a cache was cleared from being cached afterwards) was more
machinery than the narrow window justified. Removed it from both the
assignment and feature-flag caches in all three libs, along with
Python's locks, which only made the guard's check-and-write atomic.
Without the guard, cache clearing on init/group/working-group/alias
changes is best-effort: in the race, the cache ends up as stale as dev
always left it, so this is still a strict improvement. Each README gains
a Caching section that says so and tells callers to await those calls
before reading, or pass ignoreCache.
Java keeps assignmentLock and featureFlagLock: the caches are written on
Jersey threads and read on the caller's thread, and on dev that access
had no synchronization at all.
The race tests and Java's held-response test-server code are removed;
JS gains a test that changing session groups clears the flag cache.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ExperimentClientTest now checks the HTTP method and request body of every
endpoint the Java client calls against what the backend's validators
expect: /init (all three overloads), /groupmembership, /workinggroup,
/useraliases, /assign and /log join the existing /mark, /reward and
/featureflag coverage. /init is checked for its exact field set, since the
backend rejects unknown fields there (forbidNonWhitelisted). A headers test
checks User-Id, Client-Context, Content-Type, Session-Id, and that
Client-Version was filled in from pom.xml rather than left as a
placeholder.
The /groupmembership and /workinggroup beans also send null
createdAt/updatedAt/versionNumber/requestedUserId fields, which the server
strips; the tests only require that those carry no values.
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, Phase C alignment
Client lib parity: audit, Phases A–C, and Phase D Java tests
Nov 4, 2026
The reason will be displayed to describe this comment to others. Learn more.
can this change be removed too? i don't see a purpose, seems related ultimately to the same unlikely cache-clearing scenarios we backed out other changes for.
The reason will be displayed to describe this comment to others. Learn more.
same as comment for the getDecisionPointAssignment, what does have to do with making the libraries consistent? is there a bug this fixes if worrying about cache-clearing isn't worth it?
The reason will be displayed to describe this comment to others. Learn more.
if we're touching the retry stuff anyway (i see this fixes a real bug or two), we may as well set the retries and backoff as constants also, and... I think i've changed my mind on the 3 retries question, I think 1 retry is sufficient as a default, because if we're not allowing the user to configure this (they probably should be able to, but we kicked the can on that), it may be very annoying that it retries a bunch of times with no way to turn it off...
The reason will be displayed to describe this comment to others. Learn more.
i only mentioned it because there are still a number of retry changes in here, so i didn't know if you were planning on backing those out as well or what. i bring it up because this appears to fix an issue that prevented any retries from happening due to the error throwing before the retry logic, so if that is now fixed, unexpected multiple retries could start firing, which according to the docs it generated, could take up to 22 seconds to complete the way it is configured by default for 5xx type errors.
could be better handled all together as an immediate 'http-client-config-parity' follow-up branch?
The reason will be displayed to describe this comment to others. Learn more.
I'll change it to one retry here, rather than revert to a misleading state where it wasn't actually using the configured settings. We can then revisit later, along with the other timeout/retry issues.
The reason will be displayed to describe this comment to others. Learn more.
ok, yeah, a bit of history is coming back to me now; one of the original maddening things Jenn and I had to figure out the hard way when investigating another ol' assign-prog mystery. it turned out the java client was set to retry 3 times in this manual way, and it was retrying stuff like 404s that were considered non-errors in the elastic-beanstalk monitors, so it was not obvious why we had some nonsensical things going on with wild traffic and timeout issues..... anyway, what was fixed in the java client only partially made its way to the javascript one i guess, setting it to retry once on 5xx errors seems like a sane default.
tests for the js client and java seem like they are good, mainly i tested rewards and feature-flags and some basic cache-clearing. the python changes looks reasonable to me from what i can tell, python is messed up on my machine right now so i didn't run that but i am less concerned with that one anyway as it's so new i trust you've got that handled.
JS getDecisionPointAssignment/hasFeatureFlag and Python
get_decision_point_assignment/has_feature_flag searched the list their
own fetch returned rather than the cache. That only mattered while the
guard could leave a fresh response uncached; without it, every successful
fetch is cached immediately, so the cache and the returned list are the
same apart from a negligible window. All four are back to dev's
fetch-if-empty-then-read-the-cache form.
Removes the extra `assignments` parameter from JS
DataService.findExperimentAssignmentBySiteAndTarget (its null-safe lookup
from Phase A stays, for /mark after a failed /assign) and Python's
DataService.find_assignment / _assignment_in helpers.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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.
* call that may have changed them: init, group membership, working group, or aliases.
*/
clearUserDependentData() {
this.clearExperimentAssignmentData();
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. 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.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: 110/110 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.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