Skip to content

Dual-host SDK routing for /send-mail#59

Merged
etbyrd merged 4 commits into
mainfrom
dual-host-routing
May 11, 2026
Merged

Dual-host SDK routing for /send-mail#59
etbyrd merged 4 commits into
mainfrom
dual-host-routing

Conversation

@etbyrd
Copy link
Copy Markdown
Member

@etbyrd etbyrd commented May 10, 2026

Summary

/send-mail lives on a different host than the rest of the API (Cloudflare Worker at api.primitive.dev/v1, larger body cap for attachments). This PR makes all three SDKs route /send-mail there automatically so customers don't have to know about the split.

Customer-visible shape

// sdk-node
const client = new PrimitiveClient({ apiKey });
await client.send({ from, to, subject, body_text, attachments });  // routed to host 2 internally
# sdk-python
client = PrimitiveClient(api_key=...)
client.send(from_email=..., to=..., subject=..., body_text=..., attachments=[...])  # routed to host 2 internally
// sdk-go
client, _ := primitive.NewClient(apiKey)
client.Send(ctx, primitive.SendParams{...})  // routed to host 2 internally

The same /send-mail invocation auto-routes to the attachments-supporting host without the caller passing anything host-related. Every other operation continues to hit the primary host.

Override knobs

For internal staging/local testing only. Not documented in any customer-facing material.

  • Env: PRIMITIVE_API_BASE_URL_1, PRIMITIVE_API_BASE_URL_2
  • TS: new PrimitiveApiClient({ apiBaseUrl1, apiBaseUrl2 })
  • Python: PrimitiveClient(api_key, api_base_url_1=..., api_base_url_2=...)
  • Go: primitive.NewClientWithOptions(apiKey, ClientOptions{APIBaseURL1, APIBaseURL2})
  • CLI flags --api-base-url-1 / --api-base-url-2 are hidden from --help and fish completion.

Migration story

When we move another endpoint to host 2:

  1. Add per-op servers entry on that path in the OpenAPI spec.
  2. Add the operation's sdkName to HOST_2_OPERATIONS in sdk-node's api-command.ts.
  3. Switch the hand-written wrapper for that operation in sdk-python / sdk-go to use api_send_client / apiSend.
  4. Vercel host either keeps serving the moved endpoint for back-compat or returns 410 with the migration hint; decided per endpoint.

What this does NOT do (deliberately deferred)

  • 413 auto-retry to host 2 when a customer hits host 1 with attachments.
  • Per-call host override flag for power users.
  • routing.json as a single declarative source of truth: the host-2 set is small enough today that hand-maintaining it per SDK is cheaper than building build-time codegen. Revisit when the set grows past ~5 operations.

Test plan

  • make node-check (647 tests, lint, typecheck, generated-file sync)
  • make go-check (vet, fmt, tests)
  • make python-check (ruff, basedpyright, 226 tests)
  • make shared-check (cross-SDK fixture compatibility, 20 tests x 3 langs)

Open questions for review:

  • Footgun audit: customer who imports the raw generated sendEmail and passes apiClient.client (or any host-1 client) will hit host 1 and 413 on attachments. Mitigated by the wrapper being the documented surface and the 413 carrying a migration message; not closed entirely. Worth re-discussing if it bites in practice.

@greptile-apps
Copy link
Copy Markdown

greptile-apps Bot commented May 10, 2026

Greptile Summary

This PR introduces dual-host routing across all three SDKs (Node, Python, Go) so /send-mail automatically targets the Cloudflare Worker host with its larger body cap, while every other operation continues hitting the primary Vercel host. Migration handling for existing CLI users is thorough: pre-dual-host credentials (old base_url field) are detected at load time, the stale file is auto-deleted, and a clear re-login notice is printed rather than surfacing a generic "malformed credentials" error.

  • Host routing: Each SDK wraps two generated clients internally; send()/Send() routes to the host-2 client without any caller-visible change. Override knobs (PRIMITIVE_API_BASE_URL_1/2, constructor options, hidden CLI flags) exist for staging/local testing and are intentionally absent from public docs and shell completions.
  • Credential migration: StaleCredentialFormatError distinguishes the old base_url shape from genuinely corrupt JSON so the upgrade path is a one-time silent logout rather than a hard CLI failure on every command.
  • Back-compat: Go keeps DefaultBaseURL as an alias; Python raises a clear TypeError when the removed base_url kwarg is passed; the api/__init__.py low-level create_client still accepts base_url (correct — it returns a raw AuthenticatedClient, not PrimitiveClient).

Confidence Score: 5/5

Safe to merge — the routing change is well-isolated behind wrapper methods, the credential migration is graceful, and existing test suites cover the key paths.

The dual-host split is contained to constructor internals and single send-path call sites; no operation-visible behavior changes for callers who use the documented surface. Pre-dual-host credential migration auto-cleans with a clear user notice rather than hard-failing. Back-compat aliases are in place in Go and Python. The only open item is two exported helpers in api-command.ts that are never called, which does not affect runtime behavior.

sdk-node/src/oclif/api-command.ts — the exported resolveCliAuthFromFlags and baseUrlOverriddenFromFlags helpers are currently dead code; worth clarifying if they are intended for future wiring or can be removed.

Important Files Changed

Filename Overview
sdk-node/src/api/index.ts Splits DEFAULT_BASE_URL into DEFAULT_API_BASE_URL_1/2, adds _sendClient to PrimitiveApiClient, and routes sendEmail to _sendClient in PrimitiveClient.send()
sdk-node/src/oclif/auth.ts Renames base_url to api_base_url_1 in StoredCliCredentials, adds StaleCredentialFormatError for graceful pre-dual-host credential migration with auto-cleanup and user-visible notice
sdk-node/src/oclif/api-command.ts Replaces --base-url with --api-base-url-1/2 flags (hidden), adds HOST_2_OPERATIONS routing set, and exports resolveCliAuthFromFlags/baseUrlOverriddenFromFlags helpers that are currently unused
sdk-python/src/primitive/client.py Splits base_url into api_base_url_1/2, adds api_send_client, adds explicit TypeError guard for legacy base_url kwarg, and routes send()/asend() to api_send_client
sdk-go/client.go Adds apiSend field, ClientOptions struct, NewClientWithOptions constructor, and routes Send() to apiSend; NewClientFromAPI uses same client for both slots (test-only path)
sdk-node/src/oclif/commands/login.ts Replaces --base-url flag with --api-base-url-1 (hidden), saves api_base_url_1 to credentials; host-2 never stored since login only touches host 1
sdk-node/src/oclif/commands/send.ts Replaces --base-url with --api-base-url-1/2 (hidden), routes sendEmail call to apiClient._sendClient for host-2 attachment support
sdk-node/tests/oclif/auth.test.ts Updates credential fixtures to use api_base_url_1; adds test for stale pre-dual-host credential auto-logout with stderr notice and idempotent file cleanup

Reviews (4): Last reviewed commit: "Reject legacy base_url kwarg on Primitiv..." | Re-trigger Greptile

Comment thread sdk-node/src/oclif/auth.ts
Comment thread sdk-node/src/api/generated/types.gen.ts
The /send-mail endpoint is served by an attachments-supporting host (api.primitive.dev/v1, Cloudflare Worker, ~30 MiB raw body cap), distinct from the primary API host that everything else uses (primitive.dev/api/v1, Vercel, 4.5 MB body cap). This PR makes all three SDKs route /send-mail to the attachments host automatically so callers can send messages with attachments without having to think about the host split.

OpenAPI spec:
- Adds the attachments-supporting host as a second top-level servers entry.
- Marks /send-mail with a per-operation servers list that names the attachments host first. The two server entries are documentation; the routing is enforced by the hand-written SDK wrappers.

sdk-node:
- PrimitiveApiClient now holds two underlying generated clients. The existing .client property targets host 1 unchanged; a new ._sendClient property targets host 2. The PrimitiveClient.send wrapper passes ._sendClient to the generated sendEmail under the hood.
- Constructor opts apiBaseUrl1 and apiBaseUrl2 (defaults to the production hosts). Override exists for staging/local testing and is intentionally not documented to customers.
- CLI commands: --base-url flag and PRIMITIVE_API_URL env removed. Replaced by hidden --api-base-url-1 / --api-base-url-2 and PRIMITIVE_API_BASE_URL_1 / _2 envs. Fish completion no longer advertises the override flags.
- HOST_2_OPERATIONS set in api-command.ts dispatches the auto-generated sending:send-email command through the host-2 client. As more endpoints migrate to host 2 over time, add their sdkName to the set.

sdk-python:
- PrimitiveClient now constructs api_client and api_send_client (both AuthenticatedClient). The send / asend / forward / aforward wrappers route through api_send_client; reply stays on api_client. Constructor takes api_base_url_1 / api_base_url_2.
- Test helpers install MockTransport on both clients so the existing send-routing tests still capture requests.

sdk-go:
- Top-level Client now holds two underlying ogen clients. NewClient builds both with the same auth and production defaults. NewClientWithOptions accepts a ClientOptions struct with APIBaseURL1 / APIBaseURL2 overrides. NewClientFromAPI keeps a single-client shape for tests.
- The Send method routes to the host-2 client; Reply stays on host 1.

Migration story: when we move another endpoint to host 2, add the sdkName to HOST_2_OPERATIONS in sdk-node, switch the relevant wrapper in sdk-python and sdk-go to api_send_client, and add the per-op servers override to the OpenAPI spec. The Vercel host can either keep serving the moved endpoint for back-compat or return a 410 with a migration hint, decided per endpoint.

Override notes: PRIMITIVE_API_BASE_URL_1 and PRIMITIVE_API_BASE_URL_2 (env), apiBaseUrl1 / apiBaseUrl2 (TS), api_base_url_1 / api_base_url_2 (Python), and ClientOptions.APIBaseURL1 / APIBaseURL2 (Go) are deliberately undocumented in customer-facing material. They exist for internal staging/local testing only; production defaults are correct and should not be overridden by customers.
@etbyrd etbyrd force-pushed the dual-host-routing branch from cb98d56 to 0f8cb6a Compare May 10, 2026 23:49
etbyrd added 3 commits May 10, 2026 16:51
Post-dual-host the shallow copy shares both underlying clients, not just api_client; the docstring only mentioned the latter and could mislead readers wondering whether the send-host client is cloned. P2 from Greptile on PR #59.
PR #59 renames the saved credential field from base_url to api_base_url_1. Before this change, every CLI command for an already-logged-in user would hard-fail on upgrade with a generic 'credentials malformed' error.

Detect the old shape specifically in parseCredentials (presence of base_url with no api_base_url_1), throw a tagged sentinel error, and have loadCliCredentials catch it: delete the stale credentials file and emit a single-line stderr notice 'You've been logged out: your saved Primitive CLI credentials were created by an older CLI version and are no longer compatible. Run primitive login to re-authenticate.' Subsequent commands then behave as 'not logged in' and either use the env API key or prompt for login.

The detect-and-clear path is idempotent: once the file is gone the branch never fires again. Genuine malformed credentials (truly broken JSON, missing required field, etc.) still throw the original generic error so they don't get swallowed.

Test added for the migration path.
Without this guard, a Python caller still passing base_url= to PrimitiveClient.__init__ would have the value swallowed by **client_kwargs and forwarded into AuthenticatedClient(base_url=api_base_url_1, **client_kwargs), producing 'got multiple values for keyword argument base_url' pointing at internal SDK code rather than the call site.

Catch the legacy kwarg up front and raise a TypeError that names the rename explicitly so the fix is obvious. create_client and client module-level factories inherit the guard via delegation. Two tests added.

Greptile P1 on PR #59.
@etbyrd etbyrd mentioned this pull request May 11, 2026
3 tasks
@etbyrd etbyrd merged commit 18a5dfa into main May 11, 2026
10 checks passed
@etbyrd etbyrd deleted the dual-host-routing branch May 11, 2026 00:17
etbyrd added a commit that referenced this pull request May 11, 2026
Release that ships dual-host routing for /send-mail (PR #59): the Node, Python, and Go SDKs now route attachment-supporting sends to api.primitive.dev/v1 automatically while every other operation continues to hit primitive.dev/api/v1. Customers don't see the split; the routing is internal.
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.

1 participant