Skip to content

Generic per-call RequestOptions across all SDK methods#52

Merged
etbyrd merged 5 commits into
mainfrom
sdk-per-call-request-options
May 6, 2026
Merged

Generic per-call RequestOptions across all SDK methods#52
etbyrd merged 5 commits into
mainfrom
sdk-per-call-request-options

Conversation

@etbyrd
Copy link
Copy Markdown
Member

@etbyrd etbyrd commented May 5, 2026

Summary

  • Aligns Node, Python, and Go on the same per-call request-options surface that the rest of the SDK ecosystem uses (Stainless-generated SDKs, Stripe, AWS SDK v3): timeout, cancellation, headers, and idempotency key controllable on every call rather than only at client construction.
  • Node gains a RequestOptions second-arg on send/reply/forward with signal/timeout/headers/idempotencyKey. idempotencyKey moves from SendInput to RequestOptions (pre-1.0 breaking change, matches Stripe/Anthropic/OpenAI shape).
  • Python gains timeout/extra_headers/idempotency_key kwargs on every method (send/asend/reply/areply/forward/aforward) plus a client.with_options(...) clone helper. Go is already idiomatic via ctx context.Context as the first arg; README updated to document context.WithTimeout / context.WithCancel explicitly.

Convention adopted

SDK Pattern
Node client.foo(input, options?) where options is RequestOptions
Python per-call kwargs plus client.with_options(...)
Go ctx context.Context as first arg (no change)

Notable design choices

  • Timeout composition: AbortSignal.any([signal, AbortSignal.timeout(ms)]) so either fires AbortError. AbortError/TimeoutError skip the API-error repackaging path.
  • Python clone strategy uses attrs.evolve directly rather than the existing with_headers/with_timeout helpers because those mutate the underlying httpx client in place, which would leak per-call headers (including Idempotency-Key) into subsequent calls.
  • maxRetries is intentionally out of scope. Client-side retries deserve their own design conversation: which operations are safe to retry, idempotency-key requirement, exponential backoff policy.

Test plan

  • make node-check (lint, typecheck, generated-file-sync, 627 tests)
  • make python-check (ruff, basedpyright, 224 tests)
  • make go-check (gofmt, vet, tests)
  • make shared-check (cross-SDK fixture parity)
  • make node-build
  • make python-build
  • make go-build
  • New tests added: pre-aborted signal, fired timeout, custom headers merge, idempotency-key on every method, signal+timeout combined (timeout wins), with_options defaults plus per-call override (Python sync and async)

Aligns Node, Python, and Go on the same per-call request-options surface
that the rest of the SDK ecosystem has converged on (Stainless-generated
SDKs, Stripe, AWS SDK v3): timeout, cancellation, headers, and
idempotency key controllable on every call rather than only on
construction.

Node:
* New RequestOptions interface ({ signal, timeout, headers, idempotencyKey })
  accepted as an optional second argument on send, reply, forward.
* Internal helper composes timeout and caller signal via AbortSignal.any
  so either fires AbortError. AbortError and TimeoutError propagate
  through unwrapSendResult unchanged instead of being repackaged into
  PrimitiveApiError.
* idempotencyKey moves from SendInput to RequestOptions. Pre-1.0
  breaking change; matches Stripe/Anthropic/OpenAI conventions.

Python:
* New RequestOptions dataclass plus per-call timeout, extra_headers,
  idempotency_key kwargs on send/asend/reply/areply/forward/aforward.
* Internal helper clones AuthenticatedClient via attrs.evolve to apply
  per-call options without leaking headers into subsequent calls.
* New PrimitiveClient.with_options(timeout=, extra_headers=) clone
  helper for setting many calls at once. idempotency_key intentionally
  not on with_options since it is a per-call concern.

Go:
* No code change. Every method already takes ctx context.Context as
  the first argument, so per-call timeouts and cancellation work via
  context.WithTimeout / context.WithCancel today. README updated to
  document the pattern explicitly.

Cross-SDK shared fixture suite still passes. maxRetries is intentionally
out of scope: client-side retries deserve their own design conversation
(safe-to-retry operation classification, idempotency-key requirement,
exponential backoff policy).

Linear: PRI-157
@greptile-apps
Copy link
Copy Markdown

greptile-apps Bot commented May 5, 2026

Greptile Summary

This PR adds a per-call RequestOptions surface to Node and Python (Go already uses context.Context) and moves idempotencyKey out of SendInput into RequestOptions in Node. The contextvar + httpx event-hook design in Python is sound: each call sets the var before issuing the request and resets it in a finally block, giving correct isolation for both threaded and asyncio.Task-based concurrent use.

  • Node: resolveRequestOptions composes signal + timeout via AbortSignal.any, a dedicated isAbortLikeError guard rethrows abort/timeout errors unwrapped, and idempotencyKey is removed from SendInput (pre-1.0 breaking change, documented).
  • Python: _compose_options merges with_options defaults with per-call kwargs (per-call wins); with_options uses a _NotGiven sentinel so None and "omitted" are distinct states; copy.copy shares the underlying api_client between the base and clone.
  • Go: ForwardParams gains IdempotencyKey string; the existing != "" guard in Send correctly skips the header when the value is the zero value.

Confidence Score: 5/5

Safe to merge. The request-options surface is well-tested across all three SDKs and the contextvar isolation in Python is correct for both sync and async concurrent use.

All changed paths have matching new tests (pre-aborted signal, fired timeout, custom headers, idempotency key on every method, signal+timeout combined, with_options defaults vs per-call override). The single comment is a docstring precision issue that does not affect runtime correctness.

sdk-python/src/primitive/client.py — the with_options docstring mildly overstates what timeout=None achieves, but the implementation and tests are correct.

Important Files Changed

Filename Overview
sdk-python/src/primitive/client.py Core Python implementation: contextvar-based per-call options, with_options clone, _compose_options merging. One semantic gap in the with_options(timeout=None) docstring vs. actual hook behavior.
sdk-node/src/api/index.ts Node RequestOptions surface: resolveRequestOptions, AbortSignal.any composition, isAbortLikeError guard, and idempotencyKey moved from SendInput. Implementation is correct and well-tested.
sdk-go/client.go IdempotencyKey added to ForwardParams and threaded through to SendParams; empty-string guard prevents sending an empty header. No issues.
sdk-python/tests/test_client.py Comprehensive new tests covering per-call headers, timeout extension, idempotency key on all methods, with_options defaults, and no-leak between calls.
sdk-node/tests/api/client.test.ts New test cases for pre-aborted signal, timeout, custom headers, idempotency key, and combined signal+timeout on all three methods. Well-structured.
sdk-go/client_test.go New test verifies IdempotencyKey from ForwardParams threads correctly to SendEmail API params. Clean.
sdk-node/tests/api/send-payloads.test.ts Updated shared send-payloads test to pass idempotency_key via RequestOptions second arg instead of SendInput; correct migration.
sdk-python/tests/test_send_payloads.py Updated to read idempotency key from the contextvar (_per_call_options_var) instead of the generated function kwarg. Correctly adapts to the hook-based approach.

Reviews (5): Last reviewed commit: "Merge branch 'main' into sdk-per-call-re..." | Re-trigger Greptile

Comment thread sdk-python/src/primitive/client.py
Previously, _apply_request_options early-returned when all kwargs were
None, so chaining with_options(timeout=5.0) then with_options(timeout=None)
would leave the 5s timeout in place. The original api_client was returned
unchanged.

Introduce a private _NotGiven sentinel for the timeout parameter on
with_options, matching the OpenAI/Anthropic Python SDK pattern. The
three states are now distinct: omitted leaves the clone unchanged, None
clears the timeout (httpx Timeout(None) for no timeout), and a float
sets it.

extra_headers stays default-None with merge-only semantics: there is no
way to remove a header that an earlier with_options call added because
header provenance is not tracked. Documented in the docstring; callers
needing a clean baseline should construct a fresh client.

Use isinstance(timeout, _NotGiven) rather than identity comparison so
basedpyright narrows the type properly in the elif/else branches.

Linear: PRI-157
@jetpham jetpham self-requested a review May 5, 2026 23:20
jetpham
jetpham previously requested changes May 6, 2026
Comment thread sdk-go/README.md Outdated

A canceled `ctx` surfaces as `context.Canceled`; a deadline exceeded surfaces
as `context.DeadlineExceeded`. Both are distinct from API errors returned by
`*PrimitiveAPIError`, so callers can tell a client-side abort apart from a
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.

should be *primitive.APIError

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed in f73757b. README now says *primitive.APIError.

Comment thread sdk-go/README.md Outdated
// Per-call timeout: cancel after 15 seconds.
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
_, err := client.Send(ctx, primitive.SendParams{...})
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.

primitive.SendParams{...}, is not valid go

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed in f73757b. First example now uses a full struct literal (From/To/Subject/BodyText).

Comment thread sdk-go/README.md Outdated
// Per-call cancellation: bail out from another goroutine.
ctx, cancel := context.WithCancel(context.Background())
go func() { <-userBailoutSignal; cancel() }()
_, err := client.Send(ctx, primitive.SendParams{...})
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.

primitive.SendParams{...}, is not valid go

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed in f73757b. Second example uses /* params */ so it reads as a placeholder rather than something a reader would copy as-is.

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.

When using request options, we drop custom HTTP setup. This only happens in the python sdk

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Good catch. The previous attrs.evolve(api_client, ...) per call reset the init=False httpx fields, rebuilding a fresh httpx.Client each call and dropping any client installed via set_httpx_client.

Rewritten in f73757b: per-call options now flow through an httpx request hook driven by a ContextVar. The hook is installed once on the AuthenticatedClient, and with_options stores defaults on PrimitiveClient itself rather than cloning the api_client. client.api_client._client identity is now stable across calls, custom clients survive, and the contextvar keeps it safe across threads and async tasks. New tests in test_client.py cover (a) custom-client identity, (b) no header/timeout leakage between calls, (c) async parity.

Comment thread sdk-go/README.md
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.

sdk-go doesn't support Idempotency-Key on reply and forward. The other SDKs do.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Forward now supports it. IdempotencyKey on ForwardParams is threaded into the synthesized SendParams (f73757b), with test coverage in client_test.go.

Reply intentionally drops Idempotency-Key across all three SDKs. The server's reply route synthesizes its own send-mail call and forwards only content-type and authorization, so the header was a silent no-op in Node and Python. Removed from Python reply/areply, and Node rejects it via Omit<RequestOptions, "idempotencyKey"> plus a runtime guard. The SDK surface can be widened non-breakingly when the server route honors it.

Comment on lines +40 to +46
@dataclass(frozen=True, slots=True)
class RequestOptions:
"""Per-call HTTP options applied to a single client method call."""

timeout: float | None = None
extra_headers: dict[str, str] | None = None
idempotency_key: str | None = None
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.

This is never used anywhere. We only use kwargs

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Right, methods only take kwargs so the public dataclass added no value. Removed from the public surface in f73757b (no longer exported from primitive/__init__.py). Internal _PerCallOptions and _ClientDefaults dataclasses remain as private plumbing for the contextvar hook.

etbyrd added 2 commits May 5, 2026 20:27
…eanup, Go README fixes

Python: refactor per-call options to a request-time hook driven by a
contextvar, and stop cloning AuthenticatedClient per call. Previously,
attrs.evolve(api_client, ...) reset the init=False httpx client fields
on the clone, which dropped any client a user had installed via
set_httpx_client and rebuilt a fresh httpx.Client on every call,
losing connection pooling. The new approach installs a request hook
once, the hook reads per-call options from a ContextVar, and
with_options stores defaults on PrimitiveClient itself rather than
cloning the api_client. Connection pooling and custom-client identity
are preserved. The previously-exported but unused RequestOptions
dataclass is removed from the public surface.

Reply Idempotency-Key cleanup: the server's reply route synthesizes
its own request to send-mail and only forwards content-type and
authorization, so Idempotency-Key on reply was a silent no-op.
Removing it from reply / areply (Python) and rejecting it via both
the type system (Omit<RequestOptions, "idempotencyKey">) and a
runtime guard on reply (Node). idempotencyKey stays on send and on
forward (which constructs a send under the hood). When the server
adds Idempotency-Key forwarding on the reply route, the SDK surface
can be widened to accept it.

Go: add IdempotencyKey to ForwardParams and thread it through to the
SendParams that Forward synthesizes, matching cross-SDK parity for
the forward path. README example updated with valid Go syntax (the
prior `SendParams{...}` literal was not valid) and the correct error
type name (`*primitive.APIError`).

Linear: PRI-157
The earlier commit on this branch reverted Idempotency-Key support on
reply / areply because the server's reply route synthesized its own
request to send-mail and dropped any caller-supplied Idempotency-Key
header on the way through. The mono-repo PR (PRI-159) closes that gap
by also forwarding the header onto the synth request, so the customer-
supplied path now reaches send-mail's idempotency machinery.

With that gap closed, restore the original cross-SDK shape:

* Node: drop the Omit<RequestOptions, "idempotencyKey"> on reply's
  options parameter and the runtime guard that threw on the field.
  Replace the rejection test with a positive test that asserts
  Idempotency-Key is sent on reply.
* Python: re-add idempotency_key kwarg on reply / areply (and the
  _do_reply / _ado_reply internals so it threads through to the
  contextvar-driven request hook). Replace the rejection tests with
  positive forwarding tests that assert the header lands on the
  outgoing request.
* Both READMEs: drop the "reply does not accept idempotencyKey"
  caveat now that it does.

Send and forward already accept the field; this restores the originally-
intended behavior on the third method.

Linear: PRI-159
@etbyrd
Copy link
Copy Markdown
Member Author

etbyrd commented May 6, 2026

All six comments addressed in f73757b. The substantive one is the Python rewrite: per-call options now flow through a contextvar + httpx request hook installed once on the AuthenticatedClient, instead of rebuilding the http client per call. That preserves custom clients (set_httpx_client) and connection pooling.

@etbyrd etbyrd requested a review from jetpham May 6, 2026 20:51
@etbyrd etbyrd dismissed jetpham’s stale review May 6, 2026 21:06

resolved

@etbyrd etbyrd merged commit 2a90d72 into main May 6, 2026
9 checks passed
@etbyrd etbyrd deleted the sdk-per-call-request-options branch May 6, 2026 21:07
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.

2 participants