Skip to content

feat(client): Add exist_ok ConflictResolver to auto-retry on 409s - #579

Merged
matthewgrossman merged 5 commits into
mainfrom
mgrossman/aircore-866-fix-exist_ok-semantics-for-create-endpoints
Jul 6, 2026
Merged

feat(client): Add exist_ok ConflictResolver to auto-retry on 409s#579
matthewgrossman merged 5 commits into
mainfrom
mgrossman/aircore-866-fix-exist_ok-semantics-for-create-endpoints

Conversation

@matthewgrossman

@matthewgrossman matthewgrossman commented Jul 6, 2026

Copy link
Copy Markdown
Contributor

Summary

Restores exist_ok=True on create endpoints (starting with create_fileset) so a caller gets the existing entity back on conflict, without a manual follow-up GET:

fs = client.create_fileset(body=CreateFilesetRequest(name="myname"), exist_ok=True)
# -> FilesetOutput, whether it was just created OR already existed

Fixes AIRCORE-866.

Background

exist_ok was previously removed (commit 1a78606e48) because it was inert. The old implementation swallowed the ConflictError and parsed the 409 response body as the entity — i.e. it assumed the server returns the entity on 409 (Option A). The real server returns {"detail": "... already exists"}, so model_validate(raw.json()) would have failed on a genuine conflict; it only ever "worked" in tests that mocked entity-shaped 409 bodies.

Rather than change every server endpoint to return the entity on 409 (non-standard — RFC 9110 treats the 409 body as diagnostic), this handles exist_ok entirely client-side: on a 409, the client replays a linked GET and returns that entity. This is exactly what FilesetsSubResource.create did by hand via try/except + GET — lifted into the client so every consumer gets it declaratively.

How it works

A create endpoint declares a get_on_conflict resolver — a named, typed function that builds the retrieve request:

def _get_fileset_on_conflict(body: CreateFilesetRequest, workspace: str | None) -> PreparedRequest[FilesetOutput]:
    return get_fileset(name=body.name, workspace=workspace)

@post("/apis/files/v2/workspaces/{workspace}/filesets", get_on_conflict=_get_fileset_on_conflict)
@abstractmethod
def create_fileset(
    *, workspace: str | None = None, body: CreateFilesetRequest, exist_ok: bool = False
) -> FilesetOutput: ...
  • The decorator calls the resolver at request-build time (while the live body model still exists, before it's serialized to bytes) and stashes the resulting GET on PreparedRequest.on_conflict_get.
  • send() (sync + async): on 409 + exist_ok, it replays on_conflict_get and returns that entity instead of raising ConflictError. Without exist_ok, a 409 still raises as before.
  • exist_ok set on an endpoint with no resolver raises a clear ValueError (not a parse crash).
  • NemoResponse.body stays non-nullable (T) — no T | None tax on other callers.

Correctness is enforced by the type checker: the resolver returns get_fileset(name=..., workspace=...), a real typed endpoint call, so a wrong kwarg/type is caught statically at the call site.

Changes

File Change
client/types.py ConflictResolver Protocol + PreparedRequest.on_conflict_get field
client/endpoint.py @post(..., get_on_conflict=...); resolver invoked at request-build time
client/client.py _should_resolve_conflict helper; sync + async send() replay the linked GET on 409 + exist_ok
files/endpoints.py create_fileset gains exist_ok + get_on_conflict=_get_fileset_on_conflict
filesets/resources.py Drop the try/except + GET workaround in sync/async create; pass exist_ok through
tests/client/test_client_options.py Auto-GET tests using a real {"detail": ...} 409 body + follow-up GET

Testing

  • New tests cover: resolver wiring, 409+exist_ok returns the entity via auto-GET, 409 without exist_ok raises ConflictError, non-409 passthrough, missing-resolver ValueError, GET-404-after-409 surfaced, the flat client.create_item(exist_ok=True) method path, and async twins.
  • packages/nemo_platform_plugin/tests/client/ — 64 passed; full plugin suite — 871 passed.
  • ty and ruff clean on changed code.

Out of scope

  • Server-side idempotency (200 + entity keyed on Idempotency-Key, or PUT create-or-replace) — the standards-aligned path if we ever change the server; deferred.
  • Returning the entity in the 409 body (Option A) — rejected as non-standard.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added conflict-aware create behavior for HTTP 409 by replaying a prebuilt read request when exist_ok is enabled (sync and async).
    • Extended fileset and example plugin item creation to support exist_ok conflict replay.
  • Bug Fixes

    • exist_ok=True now returns the existing resource on 409 instead of requiring manual conflict handling.
    • If exist_ok is enabled without configuring conflict replay, a clear error is raised.
  • Tests

    • Added coverage for resolver wiring, replay behavior, header preservation, and error cases across client and async client modes.

Signed-off-by: Matthew Grossman <mgrossman@nvidia.com>
@matthewgrossman
matthewgrossman requested review from a team as code owners July 6, 2026 19:10
@github-actions github-actions Bot added the feat label Jul 6, 2026
Signed-off-by: Matthew Grossman <mgrossman@nvidia.com>
@coderabbitai

coderabbitai Bot commented Jul 6, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Enterprise

Run ID: c130deec-c7f6-4be1-912d-27f5949e9c2a

📥 Commits

Reviewing files that changed from the base of the PR and between 045ee87 and 999746b.

📒 Files selected for processing (1)
  • plugins/example-plugin/src/nemo_example_plugin/types/endpoints.py

📝 Walkthrough

Walkthrough

Adds end-to-end 409 replay support for exist_ok: endpoint requests can carry a conflict GET resolver, clients replay it on 409, fileset creation uses it, and tests cover sync, async, and wrapper paths.

Changes

exist_ok Conflict Replay via 409 GET

Layer / File(s) Summary
ConflictResolver protocol and request field
packages/nemo_platform_plugin/.../client/types.py
Adds ConflictResolver and optional on_conflict_get on PreparedRequest.
Endpoint builder and decorator wiring
packages/nemo_platform_plugin/.../client/endpoint.py
Threads get_on_conflict through request preparation and rejects exist_ok without a resolver.
Client send replay
packages/nemo_platform_plugin/.../client/client.py
Sync and async send() replay on_conflict_get on 409 when exist_ok is set; missing resolver raises ValueError.
Fileset and example endpoint wiring
packages/nemo_platform_plugin/.../files/endpoints.py, plugins/example-plugin/.../types/endpoints.py
create_fileset and example create_item both gain conflict GET resolvers for replay.
Filesets resource delegation
packages/filesets/src/filesets/resources.py
Sync and async create() pass exist_ok through and return the client result directly.
Tests
packages/nemo_platform_plugin/tests/client/test_client_options.py
Adds wiring, sync, async, and wrapper tests for resolver behavior and replay paths.

Sequence Diagram(s)

sequenceDiagram
  participant Caller
  participant Client as NemoClient.send
  participant API as HTTP API
  participant Resolver as on_conflict_get

  Caller->>Client: create(..., exist_ok=True)
  Client->>API: POST create
  API-->>Client: 409 Conflict
  Client->>Resolver: replay GET request
  Resolver-->>Client: PreparedRequest
  Client->>API: GET existing entity
  API-->>Client: 200 OK
  Client-->>Caller: entity
Loading

Possibly related PRs

Suggested labels: feat

Suggested reviewers: maxdubrinsky, mckornfield

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed Clear and specific; it matches the main client-side exist_ok/409 conflict-retry change.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch mgrossman/aircore-866-fix-exist_ok-semantics-for-create-endpoints

Comment @coderabbitai help to get the list of available commands.

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

Actionable comments posted: 1

🧹 Nitpick comments (3)
packages/nemo_platform_plugin/tests/client/test_client_options.py (2)

182-201: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Consider adding an async equivalent for the flat-method-wrapper replay.

TestExistOkViaMethod only covers the sync path via method(CREATE_ITEM_LINKED); TestAsyncExistOkViaSend covers async but only through client.send() directly, not through a method()-built wrapper on AsyncNemoClient. Given async is a fully supported path per the client layer, an async flat-wrapper test would close the coverage gap.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/nemo_platform_plugin/tests/client/test_client_options.py` around
lines 182 - 201, Add an async coverage test for the flat method-wrapper replay
path, since `TestExistOkViaMethod` only exercises `method(CREATE_ITEM_LINKED)`
on the sync client. Mirror that scenario with `AsyncNemoClient` by defining an
async wrapper class using `method()` and verifying `exist_ok=True` on
`create_item` retries/replays correctly after a 409, similar to the existing
async send-based test.

112-121: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Local method param shadows imported method helper.

_resp's method: str = "POST" parameter shadows the method import from nemo_platform_plugin.client.method (Line 15) within this function's scope. No functional bug since scope is local, but worth a distinct name (e.g. http_method) for clarity given method is also used as a class-level attribute name in TestExistOkViaMethod.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/nemo_platform_plugin/tests/client/test_client_options.py` around
lines 112 - 121, The `_resp` helper in `test_client_options.py` uses a local
parameter name that shadows the imported `method` helper, which hurts
readability and can be confusing alongside `TestExistOkViaMethod.method`. Rename
the `_resp` parameter to something clearer like `http_method`, and update its
use inside `_resp` so the helper remains unambiguous while preserving the
existing behavior.
packages/nemo_platform_plugin/src/nemo_platform_plugin/client/endpoint.py (1)

170-200: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Fail fast: validate exist_ok requires a resolver at decoration time.

Today a create endpoint can declare exist_ok without get_on_conflict; the misconfiguration only surfaces as a ValueError the first time a real 409 occurs in production (see _should_resolve_conflict in client.py). client_option_names is already available here — use it to catch this at import time instead.

♻️ Proposed early validation
     hints = get_type_hints(fn)
     ret = hints.get("return")
     response_type = ret if ret is not None and ret is not type(None) else None
+
+    if "exist_ok" in client_option_names and get_on_conflict is None:
+        raise ValueError(
+            f"{fn.__name__} declares an 'exist_ok' option but no get_on_conflict "
+            "resolver was provided; a real 409 would fail instead of retrieving "
+            "the existing entity. Add get_on_conflict=<resolver> to `@post`(...)."
+        )
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/nemo_platform_plugin/src/nemo_platform_plugin/client/endpoint.py`
around lines 170 - 200, The _make_endpoint decorator should fail fast when an
endpoint exposes the exist_ok client option but no get_on_conflict resolver was
provided. Use the existing client_option_names check in _make_endpoint,
alongside _validate_params, to validate this at decoration/import time instead
of deferring the error until _should_resolve_conflict is hit in client.py. If
the misconfiguration is detected, raise a clear ValueError before returning
prepare so create endpoints are configured correctly up front.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/nemo_platform_plugin/src/nemo_platform_plugin/client/client.py`:
- Around line 404-406: The 409 replay in `send()` drops caller-supplied headers
because the recursive call to `request.on_conflict_get` omits the original
`headers` argument. Update both the sync and async conflict-handling paths in
`Client.send` / the corresponding async send method so the replay passes through
`headers=headers` (or the equivalent merged headers) when calling `self.send` on
`request.on_conflict_get`, preserving caller-provided headers across retries.

---

Nitpick comments:
In `@packages/nemo_platform_plugin/src/nemo_platform_plugin/client/endpoint.py`:
- Around line 170-200: The _make_endpoint decorator should fail fast when an
endpoint exposes the exist_ok client option but no get_on_conflict resolver was
provided. Use the existing client_option_names check in _make_endpoint,
alongside _validate_params, to validate this at decoration/import time instead
of deferring the error until _should_resolve_conflict is hit in client.py. If
the misconfiguration is detected, raise a clear ValueError before returning
prepare so create endpoints are configured correctly up front.

In `@packages/nemo_platform_plugin/tests/client/test_client_options.py`:
- Around line 182-201: Add an async coverage test for the flat method-wrapper
replay path, since `TestExistOkViaMethod` only exercises
`method(CREATE_ITEM_LINKED)` on the sync client. Mirror that scenario with
`AsyncNemoClient` by defining an async wrapper class using `method()` and
verifying `exist_ok=True` on `create_item` retries/replays correctly after a
409, similar to the existing async send-based test.
- Around line 112-121: The `_resp` helper in `test_client_options.py` uses a
local parameter name that shadows the imported `method` helper, which hurts
readability and can be confusing alongside `TestExistOkViaMethod.method`. Rename
the `_resp` parameter to something clearer like `http_method`, and update its
use inside `_resp` so the helper remains unambiguous while preserving the
existing behavior.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Enterprise

Run ID: f4f2c71c-1b51-466b-9de0-2209f94d3928

📥 Commits

Reviewing files that changed from the base of the PR and between fb50b86 and 2c73a08.

📒 Files selected for processing (6)
  • packages/filesets/src/filesets/resources.py
  • packages/nemo_platform_plugin/src/nemo_platform_plugin/client/client.py
  • packages/nemo_platform_plugin/src/nemo_platform_plugin/client/endpoint.py
  • packages/nemo_platform_plugin/src/nemo_platform_plugin/client/types.py
  • packages/nemo_platform_plugin/src/nemo_platform_plugin/files/endpoints.py
  • packages/nemo_platform_plugin/tests/client/test_client_options.py

Comment thread packages/nemo_platform_plugin/src/nemo_platform_plugin/client/client.py Outdated
@github-actions

github-actions Bot commented Jul 6, 2026

Copy link
Copy Markdown
Contributor
Suite Lines Covered Line Rate Branch Rate
Unit Tests 23251/30416 76.4% 61.2%
Integration Tests 13596/29096 46.7% 20.0%

matthewgrossman and others added 2 commits July 6, 2026 12:29
Signed-off-by: Matthew Grossman <mgrossman@nvidia.com>
- Preserve caller-supplied headers on the 409 conflict-replay GET (sync + async).
- Fail fast at decoration time when an endpoint declares exist_ok without a
  get_on_conflict resolver, instead of raising on the first real 409.
- Rename the _resp test helper's shadowing `method` param to `http_method`.
- Add async flat-method-wrapper replay coverage + a header-preservation test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Matthew Grossman <mgrossman@nvidia.com>
@matthewgrossman

Copy link
Copy Markdown
Contributor Author

Addressed the review feedback in 045ee87 (all four items):

Actionable — dropped headers on conflict replay
The recursive send() for the conflict-GET now passes headers=headers through (both sync and async paths), so caller-supplied per-request headers survive the replay. Added test_caller_headers_are_preserved_on_conflict_replay.

Fail fast: exist_ok requires a resolver
Moved the check to decoration time in _make_endpoint — an endpoint declaring exist_ok without get_on_conflict now raises TypeError at import, not on the first production 409. Used TypeError to match the existing decoration-time validation style (_validate_params). The runtime ValueError in _should_resolve_conflict stays as a defensive backstop. Added test_exist_ok_without_resolver_raises_at_decoration_time; the former runtime-error test was replaced accordingly.

method param shadowing in _resp
Renamed to http_method.

Async flat-method replay coverage
Added TestAsyncExistOkViaMethod mirroring the sync method()-wrapper test on AsyncNemoClient.

All 66 client tests pass; ty/ruff clean on changed code.

The new decoration-time check (exist_ok requires a get_on_conflict resolver)
surfaced that the example plugin's create_item declared exist_ok without one,
failing at import. Link it to get_item, mirroring create_fileset.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Matthew Grossman <mgrossman@nvidia.com>
@matthewgrossman
matthewgrossman added this pull request to the merge queue Jul 6, 2026
Merged via the queue into main with commit 7365168 Jul 6, 2026
55 checks passed
@matthewgrossman
matthewgrossman deleted the mgrossman/aircore-866-fix-exist_ok-semantics-for-create-endpoints branch July 6, 2026 20:55
arpitsardhana pushed a commit that referenced this pull request Jul 9, 2026
* feat(client): Add exist_ok ConflictResolver to auto-retry on 409s

Signed-off-by: Matthew Grossman <mgrossman@nvidia.com>

* remove comments

Signed-off-by: Matthew Grossman <mgrossman@nvidia.com>

* lint

Signed-off-by: Matthew Grossman <mgrossman@nvidia.com>

* fix(client): address review feedback on exist_ok

- Preserve caller-supplied headers on the 409 conflict-replay GET (sync + async).
- Fail fast at decoration time when an endpoint declares exist_ok without a
  get_on_conflict resolver, instead of raising on the first real 409.
- Rename the _resp test helper's shadowing `method` param to `http_method`.
- Add async flat-method-wrapper replay coverage + a header-preservation test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Matthew Grossman <mgrossman@nvidia.com>

* fix(example-plugin): wire get_on_conflict resolver on create_item

The new decoration-time check (exist_ok requires a get_on_conflict resolver)
surfaced that the example plugin's create_item declared exist_ok without one,
failing at import. Link it to get_item, mirroring create_fileset.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Matthew Grossman <mgrossman@nvidia.com>

---------

Signed-off-by: Matthew Grossman <mgrossman@nvidia.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants