Repository navigation
Conversation
Switch run() from unconditional POST …/evaluations to GET → POST|PATCH
on …/offline-evaluations with required key+name. Reuse stable keys across
CI; patch a full config snapshot when desired state drifts. Runs remain
under …/evaluations/{id}/runs until nested routes ship.
Co-authored-by: doneill <doneill@launchdarkly.com>
Drop client-side config equality. On GET 200 (or after a POST 409 re-GET), always send the full desired snapshot and let the server decide whether anything changed. Always include tools/criteria (including []) so PATCH can clear pins. Co-authored-by: doneill <doneill@launchdarkly.com>
…PATCH
Omitting those fields left prior settings on a reused evaluation. Send {}
so the snapshot can clear them, and mark full-snapshot PATCH idempotent.
Co-authored-by: doneill <doneill@launchdarkly.com>
| # Full-snapshot PATCH is idempotent: replaying the same body is a | ||
| # server no-op when nothing changed, so transient 5xx may be retried. | ||
| raw = require_mapping( | ||
| self._api.patch(path, body=patch_body, idempotent=True), |
There was a problem hiding this comment.
🔴 Retry overwrites newer evaluation settings
When another writer updates a key during a failed PATCH retry, the retry reapplies an older snapshot. LDApiClient retries 5xx responses without checking the evaluation version, so the newer settings disappear.
Learn more
LDApiClient.request retries an idempotent PATCH after 5xx or a transport timeout without reading the record again (retry loop). A full desired snapshot is safe to replay only while no other writer has changed that evaluation. If the first PATCH took effect but its response was lost, a second writer can update the key before this client's backoff ends. Replaying the first body then treats the second writer's configuration as a change and replaces it. The retry can also change the evaluation version returned to the run.
Example: Worker A PATCHes support-qa to model gpt-4o and receives 503 after application. Worker B PATCHes it to claude-sonnet; A's automatic retry restores gpt-4o.
Recommended fix: Keep this PATCH non-retryable on ambiguous failures, or add server-enforced conditional writes using a version/ETag so a replay fails when another update intervenes. If retries are necessary, re-read and reconcile under that server-side concurrency constraint.
Was this helpful? React with 👍 or 👎 to provide feedback.
There was a problem hiding this comment.
Should fix before merge
- PATCH sends
name: key, so every CI run resets a renamed evaluation. - The key format isn't checked before network I/O.
_validate_run_args(module.py:769) only rejects blank keys. The server's create rule is^[a-z0-9][a-z0-9._-]*$, at most 128 characters (the GET path allows uppercase). So"Support-QA"gets a 404 on GET and then a 400 on POST, and a key with:or a space gets a 400 on the GET. These failures come after the tool and dataset requests have already run. Before this change the key was only used asname, so any string worked, and timestamp keys from the old README with colons will now fail. TESTING.md says "All validation happens before any network I/O". May need to be called out as a behaviour change in the release notes. - A PATCH 409 isn't handled. The PATCH route returns 409 when two updates collide (launchdarkly/ai-evaluator#923
test_returns_409_on_optimistic_lock), which is the likely failure when concurrent CI jobs share a key. Right now it surfaces as a rawLDApiError. Consider retrying once after a re-GET, or at least raising anEvaluationsErrorthat explains it.
Smaller points
- Missing
version._parse_evaluation_ref(runner.py:630) turns a missing or non-integerversionintoNone, and that goes out asevaluationVersion. TESTING.md and JS both treat it as an error. This predates the PR, but the PATCH response is now the source of the version, so it's a good moment to fix. Note thatisinstance(True, int)also accepts a bool. - Response
"key": null.raw.get("key", raw.get("name", ...))returnsNonewhen the response has an explicit"key": null; JS falls back toname, thenlabel, then the caller's key. Same forparameters,prompt_snippetsandmessagespassed explicitly asNone: Python sendsnull, while JS sends{}or[]. Would be good to be consistent across both.
Drafted with Claude Code and cross-checked against the server and gonfalon code.
Overview
Implements the SDK side of the idempotent offline-eval create plan (
docs/idempotent-eval-create.md).run(key=…)now reconciles via public…/offline-evaluations:…/offline-evaluations/{key}key+name(name defaults to key); on 409 race, re-GET and continue…/evaluations/{id}/runs(runs stay on the legacy by-id prefix until nested offline-evaluations runs ship server-side)No PUT. Legacy
POST …/evaluationscreate-always remains on the server for SDK 1.0 clients; this release no longer calls it.Create/PATCH bodies always include
toolsandcriteria(including[]) so a PATCH can clear prior pins.Depends on server stack: ai-evaluator#922 (schema), ai-evaluator#923 (API).
Contract update: companion draft on
ai-sdks-monorepoforTESTING.md§8.4–§8.5.Version / docs
>=1.1.0for this reconcile behavior (release-please will bump fromfeat(evaluations):on merge).key="support-qa"instead of timestamp-suffixed keys.Testing strategy
211 passed in
test_evaluations_run.py, including:LDApiClient.patchJSON bodyOther
Out of scope here: moving runs/summary/upload under
…/offline-evaluations/{id}/…(server follow-on); soft-hide null-key UI; Mintlify docs.