Skip to content

Client lib parity: audit and Phases A–D - #3408

Merged
bcb37 merged 21 commits into
devfrom
wip/clientlib-parity
Nov 10, 2026
Merged

bcb37 merged 21 commits into
devfrom
wip/clientlib-parity

Conversation

@bcb37

@bcb37 bcb37 commented Oct 28, 2026 •

Copy link
Copy Markdown
Collaborator

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 /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 before onSuccess runs. Previously a call chained from inside the callback (e.g. a mark) could hit a cold cache.
  • Null-safe callbacks, null-safe properties map, null-safe target lookup.
  • 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.

  • JS logCaliper(): POST /v6/log/caliper was removed in remove all client API code for versions < 5 #1985.
  • 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:

  • Retried: /init, /groupmembership, /workinggroup, /useraliases (upserts), /assign (read-only), and /featureflag (records exposures if-not-exists).
  • 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 null getAssignedCondition(), 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 null createdAt/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.

🤖 Generated with Claude Code

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>
Copilot AI balanced review requested due to automatic review settings October 28, 2026 19:20
- 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>
@bcb37 bcb37 changed the title Client lib parity audit + Phase A correctness fixes Client lib parity audit + Phase A,B correctness fixes Oct 28, 2026
@bcb37 bcb37 changed the title Client lib parity audit + Phase A,B correctness fixes Client lib parity audit Oct 28, 2026

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Changes recommended

Retries can duplicate non-idempotent writes and bias future assignments, while Python event-loop changes leave previous HTTP connection pools unclosed.

2 open issues
What changed in this PR

This PR audits parity across the JavaScript, Java, and Python client libraries and implements Phase A correctness fixes without backend changes.

Changes:

  • Java marks include cached assignment IDs, rotate factors with conditions, and populate caches before callbacks.
  • JavaScript gains working retries, a default timeout, reward response fields, and null-safe assignment lookup.
  • Python uses native synchronous requests and pooled HTTP clients with lifecycle methods; tests and documentation cover the updates.
File Description
clientlibs/​python/​tests/​test_client.py Adds sync, connection reuse, and lifecycle tests.
clientlibs/​python/​src/​upgrade_client_lib/​client.py Uses native sync calls and exposes cleanup methods.
clientlibs/​python/​src/​upgrade_client_lib/​api_service.py Pools HTTP clients and tracks async loop ownership.
clientlibs/​js/​src/​types/​Interfaces.ts Adds reward response mode and success fields.
clientlibs/​js/​src/​DefaultHttpClient/​DefaultHttpClient.ts Implements retries, backoff, and a default timeout.
clientlibs/​js/​src/​DefaultHttpClient/​DefaultHttpClient.spec.ts Tests retry behavior and timeout configuration.
clientlibs/​js/​src/​DataService/​DataService.ts Guards assignment lookup against a null cache.
clientlibs/​js/​src/​DataService/​DataService.spec.ts Tests lookup before assignments are loaded.
clientlibs/​js/​src/​ApiService/​ApiService.spec.ts Corrects expected mark bodies to include context.
clientlibs/​java/​src/​test/​java/​org/​upgradeplatform/​client/​ExperimentClientTest.java Adds HTTP-backed client regression tests.
clientlibs/​java/​src/​main/​java/​org/​upgradeplatform/​utils/​APIService.java Allows a null properties map.
clientlibs/​java/​src/​main/​java/​org/​upgradeplatform/​responsebeans/​Assignment.java Includes assignment IDs in mark requests.
clientlibs/​java/​src/​main/​java/​org/​upgradeplatform/​client/​ExperimentClient.java Updates mark metadata, rotation, cache timing, and null handling.
clientlibs/​java/​CLAUDE.md Documents tests and configurable timeouts.
clientlibs/​java/​.gitignore Ignores TestNG output.
clientlibs/​CLIENT_LIB_PARITY_AUDIT.md Records client differences and a phased reconciliation plan.
CLAUDE.md Corrects the client-library listing to name Python.

Review effort: Balanced

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 bcb37 changed the title Client lib parity audit Client lib parity: audit, Phase A fixes, Phase B feature parity Oct 28, 2026
@bcb37
bcb37 requested review from danoswaltCL and zackcl October 28, 2026 19:33
…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>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 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.

2 open issues
2 resolved since last review

Review effort: Balanced

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>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔵 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

Medium severity Protect lazy HTTP client initialization from concurrent access

clientlibs/​python/​src/​upgrade_client_lib/​api_service.py:120

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.

Medium severity Use a stable feature-flag options snapshot during request construction

clientlibs/​python/​src/​upgrade_client_lib/​api_service.py:333

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.

2 resolved since last review

Review effort: Balanced

bcb37 and others added 5 commits October 29, 2026 15:42
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 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
@danoswaltCL

Copy link
Copy Markdown
Collaborator

@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 AI commented Nov 3, 2026 •

Copy link
Copy Markdown
Contributor

@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.

bcb37 and others added 2 commits November 6, 2026 14:31
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 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

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 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.

2 open issues

Review effort: Balanced

* call that may have changed them: init, group membership, working group, or aliases.
*/
clearUserDependentData() {
this.clearExperimentAssignmentData();

@danoswaltCL danoswaltCL Nov 9, 2026 •

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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 AI commented Nov 9, 2026

Copy link
Copy Markdown
Contributor

@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>
@bcb37
bcb37 marked this pull request as ready for review November 9, 2026 21:28
@bcb37
bcb37 requested a balanced review from Copilot November 9, 2026 21:29

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔵 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.

1 open issue
1 resolved since last review

Review effort: Balanced

…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>
@bcb37

bcb37 commented Nov 9, 2026

Copy link
Copy Markdown
Collaborator Author

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.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟢 Approval recommended

No active review issues are reported for the client-library API parity changes.

1 open issue

Review effort: Balanced

@bcb37
bcb37 merged commit 288bf64 into dev Nov 10, 2026
7 checks passed
@bcb37
bcb37 deleted the wip/clientlib-parity branch November 10, 2026 19:21
danoswaltCL added a commit that referenced this pull request Nov 11, 2026
…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>
danoswaltCL added a commit that referenced this pull request Nov 11, 2026
…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>
danoswaltCL added a commit that referenced this pull request Nov 11, 2026
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants