Skip to content

feat: unify the SDK with the Seam SDKs for other languages - #460

Open
razor-x wants to merge 2 commits into
mainfrom
claude/php-sdk-unification-vyv0yj
Open

feat: unify the SDK with the Seam SDKs for other languages#460
razor-x wants to merge 2 commits into
mainfrom
claude/php-sdk-unification-vyv0yj

Conversation

@razor-x

@razor-x razor-x commented Aug 6, 2026

Copy link
Copy Markdown
Member

The Python, Ruby, and JavaScript SDKs share a runtime core, a common README skeleton, and a test suite aligned to the same baseline. This SDK already had the codegen and release tooling but not the runtime surface. This brings it in line.

The parity gaps were found by reading client.ts, client.py, request.rb, and SeamClient.php side by side rather than by comparing READMEs, which is how the retry layer and several behavioral divergences surfaced at all — none of them are documented anywhere.

Added

  • Personal access token authentication, with the seam-workspace header, plus token format validation that rejects client session tokens, JWTs, and publishable keys with a specific message instead of letting the server return an opaque 401.
  • from_api_key, from_personal_access_token, and from_client factories.
  • SeamMultiWorkspace for the endpoints that are not scoped to a workspace, exposing only workspaces->list() and workspaces->create().
  • SeamWebhook, verifying incoming webhooks with svix and returning a typed Event.
  • SEAM_ENDPOINT support, plus the deprecated SEAM_API_URL and both of its warnings.
  • Retries, two by default with exponential backoff, via caseyamcl/guzzle_retry_middleware.
  • HTTP layer configuration: guzzle_options, retries, and an injectable client.
  • A client level wait_for_action_attempt default, accepting a bool or a timeout and polling_interval.
  • A test suite covering auth, env, headers, errors, malformed responses, retries, pagination, serialization, action attempts, and webhooks, run against @seamapi/fake-seam-connect.
  • Psalm, wired into composer lint.

A note on retry semantics

A request that never reached the server (a connection failure) is always retried. A request that did reach the server is only retried on a retryable status when the HTTP method is idempotent.

Every Seam endpoint is a POST, so in practice an SDK call retries on transport failures and never because of a response status. Retrying a POST the server may already have processed could duplicate a write — double-creating an access code, say. Every sibling SDK makes the same trade, and Ruby asserts it explicitly (spec/seam_client/retry_spec.rb: "does not retry POST requests by default"). tests/RetryTest.php asserts it here too.

Fixed

Bugs found in the existing client while doing this:

  • Responses in the 3xx range were treated as successful (the check was >= 400 rather than outside 200–299).
  • The Seam error check accepted any body with a truthy error key. It now checks the content type and that error.type and error.message are strings, matching the five-condition predicate the other SDKs use.
  • throw_http_errors let Guzzle throw before the SDK could map the error, making the entire error-mapping block unreachable when set. The option is gone; errors always map.
  • Malformed JSON silently decoded to null and then failed on property access. The try/catch around json_decode was dead code, since it returns null rather than throwing.
  • Non-Seam error responses raised an exception built from a fabricated request with a relative URI and no headers, rather than the real one.
  • getRequestId() returned "" rather than null when the header was absent, and the fallback error type was unknown rather than unknown_error.
  • HttpInvalidInputError never actually overrode the error code — it wrote to a dynamic property because the parent's was private — and the action attempt errors wrote to an undeclared $name property (a PHP 8.2 deprecation that CI would have hit on 8.5).
  • Paginator::firstPage() indexed its cache unconditionally, and the null cursor guard was unreachable because the parameter was typed non-nullable.

Two more came out of writing the tests: the SEAM_API_KEY environment variable was overriding an explicitly passed personal access token, and Guzzle resolves its handler stack in reverse, so a history middleware pushed by a caller sits outside the SDK's retry middleware and cannot observe retries at all — which would have made the retry assertions silently vacuous.

Deliberate divergences

Both are documented in the README:

  • A 60 second default request timeout. No sibling SDK sets one. Keeping it avoids silently regressing existing PHP users; it is now overridable via guzzle_options, which it was not before.
  • Non-Seam error bodies surface the transport exception, matching the siblings, rather than being wrapped in a Seam exception.

Breaking changes

Covered in the new "Upgrading from 3.x" README section.

  • PHP 8.1 or later is required, and svix/svix is a new dependency.
  • The client is Seam\Seam; Seam\SeamClient remains as a deprecated alias.
  • The constructor takes named options, so endpoint is no longer the second positional argument, and throw_http_errors is removed.
  • Exceptions moved to the Seam\Exceptions namespace.
  • poll_until_ready() is removed in favor of wait_for_action_attempt, whose defaults change from 20s/0.4s to 10s/1s.
  • $seam->client is a Seam\Http\SeamHttpClient; the Guzzle client is available via $seam->client->get_client().
  • The $api_key property and the global LTS_VERSION constant are removed.
  • Responses in the 3xx range are no longer treated as successful.
  • Requests are now retried.
  • Pagination metadata is a Seam\Pagination object rather than a stdClass.

Notes on the diff

  • src/Seam.php is the generated client; src/SeamClient.php is now a three-line handwritten alias. The split is required by PSR-4, and the linguist-generated mark moved accordingly.
  • PHPUnit went 9 → 10 because PHPUnit 9 pins nikic/php-parser v4 while Psalm 6 needs v5. The config was migrated to the 10.5 schema.
  • The CI matrix was 8.0, 8.5; it is now 8.1 through 8.5, so the versions in between are actually exercised.
  • Coverage now excludes src/Resources and src/Routes, matching Ruby's SimpleCov filters — measuring coverage on generated code only creates pressure to test the generator.

Verification

Run locally against PHP 8.4:

  • vendor/bin/phpunit — 89 tests, 150 assertions, all passing against a real fake-seam-connect instance
  • vendor/bin/psalm — no errors
  • composer validate --strict, npm run lint, npx tsc --noEmit — all clean
  • npm run generate twice — second run produces an empty diff, so the generator stays idempotent as generate.yml requires

Plus a smoke script exercising construction via env var, api key, from_api_key, from_personal_access_token, from_client, and the deprecated alias; unlock_door with waiting on, off, and configured; the paginator's firstPage/nextPage/flatten; multi-workspace workspaces->list(); and a webhook verify round trip.

CI is the real check for 8.1 through 8.3 and 8.5, which this environment could not exercise.


Generated by Claude Code

razor-x and others added 2 commits August 6, 2026 08:10
The Python, Ruby, and JavaScript SDKs share a runtime core, a common README
skeleton, and a test suite aligned to the same baseline. This SDK had the
codegen and release tooling but not the runtime surface. This brings it in
line.

Added:

- Personal access token authentication, with the seam-workspace header, and
  token format validation that rejects client session tokens, JWTs, and
  publishable keys with a specific message.
- from_api_key, from_personal_access_token, and from_client factories.
- SeamMultiWorkspace for the endpoints that are not scoped to a workspace.
- SeamWebhook, verifying incoming webhooks with svix.
- SEAM_ENDPOINT support, plus the deprecated SEAM_API_URL and its warnings.
- Retries, two by default with exponential backoff, via
  caseyamcl/guzzle_retry_middleware. A request that never reached the server
  is always retried; a status code is only retried for idempotent methods,
  since retrying a POST the server may already have processed could duplicate
  a write. The other Seam SDKs make the same trade.
- HTTP layer configuration: guzzle_options, retries, and an injectable client.
- A client level wait_for_action_attempt default, accepting a bool or a
  timeout and polling_interval.
- A test suite covering auth, env, headers, errors, malformed responses,
  retries, pagination, serialization, action attempts, and webhooks, run
  against @seamapi/fake-seam-connect.
- Psalm, wired into composer lint.

Fixed:

- Responses in the 3xx range were treated as successful.
- The Seam error check accepted any body with a truthy error key. It now
  checks the content type and that error.type and error.message are strings,
  matching the other SDKs.
- throw_http_errors let Guzzle throw before the SDK could map the error,
  making the whole error mapping unreachable. The option is gone.
- Malformed JSON silently decoded to null and then failed on property access.
- Non-Seam error responses raised an exception built from a fabricated
  request rather than the real one.
- getRequestId returned an empty string rather than null when the header was
  absent, and the fallback error type was unknown rather than unknown_error.
- HttpInvalidInputError never actually overrode the error code, and the
  action attempt errors wrote to an undeclared property.
- Paginator::firstPage indexed its cache unconditionally, and the null cursor
  guard was unreachable.

BREAKING CHANGE: The client is Seam\Seam; Seam\SeamClient remains as a
deprecated alias. The constructor takes named options, so endpoint is no
longer the second positional argument, and throw_http_errors is removed.
Exceptions moved to the Seam\Exceptions namespace. poll_until_ready is
removed in favor of wait_for_action_attempt, whose defaults change from
20s/0.4s to 10s/1s. $seam->client is a Seam\Http\SeamHttpClient rather than a
Guzzle client. The $api_key property and the global LTS_VERSION constant are
removed. Responses in the 3xx range are no longer treated as successful.
Requests are now retried. Pagination metadata is a Seam\Pagination object.
PHP 8.1 or later is required, and svix/svix is a new dependency.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fwgphpc9YhtxBbxuxppy8m
The core public API is small enough to read at a glance, so nesting part of
it under Seam\Exceptions bought organization it does not need.

Keeping the classes where 3.x had them also means existing catch blocks keep
working. Sub-namespacing errors is the more common PHP convention, but the
Python and JavaScript SDKs both export theirs at the package root, so this is
closer to them as well.

The new SeamException marker interface, InvalidOptionsError, and
InvalidTokenError are all that changes for a caller upgrading from 3.x.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fwgphpc9YhtxBbxuxppy8m
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