Skip to content

fix: Raise a Seam error for a success response that is malformed - #475

Merged
razor-x merged 1 commit into
betafrom
claude/php-audit-uoa7nb-m3-unexpected-response
Aug 19, 2026
Merged

fix: Raise a Seam error for a success response that is malformed#475
razor-x merged 1 commit into
betafrom
claude/php-audit-uoa7nb-m3-unexpected-response

Conversation

@razor-x

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

Copy link
Copy Markdown
Member

Fixes finding M3 from the cross-SDK audit (the PHP half).

Renamed to InvalidResponseError per review, and rebased onto beta at 4ee0b79 (4.0.0-beta.16).

The problem

Generated methods read the resource straight off the decoded envelope, unguarded, at every unwrap site:

return Device::from_json($res->device);

A 200 whose body was rewritten or truncated on the way back — a proxy, a gateway maintenance page served with a JSON content type, a load balancer — leaves that read as null, and the failure surfaces from the return type instead:

TypeError: Seam\Routes\DevicesClient::get(): Return value must be of type Seam\Resources\Device, null returned

List endpoints get array_map(): Argument #2 ($array) must be of type array, string given.

Both are an Error, not an Exception — invisible to catch (\Exception) and to catch (SeamException), despite SeamException's stated promise that one catch block covers everything the SDK raises. And neither says anything about what was wrong with the response.

The fix

Seam\InvalidResponseError implements SeamException, raised from two new helpers on Body:

return Device::from_json(Body::read($res, "device", "/devices/get"));

return array_map(
    fn($r) => Device::from_json($r),
    Body::read_list($res, "devices", "/devices/list"),
);

Keeping the guard in one tested place leaves each generated call site a single call rather than a repeated block. The error carries getPath() and getKey(), and its message names both:

Seam returned an invalid response for /devices/get: expected "device", which the response does not contain

ResolveActionAttempt read the same way and raised a bare \UnexpectedValueException — equally invisible to a Seam catch block. It now raises the same error.

grep -rn '\$res->' src/Routes returns 0.

Tests

tests/MalformedResponseTest.php covered only 500s, which never reach the unwrap at all — the audit's "only php has a malformed-response file, and it covers only 500s". Eight new cases, all failing against beta with the TypeErrors above (verified: 7 TypeErrors plus the poll case):

Case On beta
missing the response key TypeError: … Device, null returned
the wrong response key (devices for device) same
JSON that is not an object same
empty body same
unparseable JSON same
HTML gateway page with a JSON content type same
list key holding a string TypeError: array_map(): Argument #2 …
malformed action-attempt poll response raises, but not a SeamException

Each asserts the error is a SeamException and that getPath()/getKey() identify the endpoint and key.

Full suite: 241 tests green, zero deprecations, psalm clean, npm run lint clean. npm run generate produces exactly the committed diff.

Scope

M3 is a cross-SDK finding; this is the PHP part. It does not change how a non-2xx response without a Seam error envelope behaves — that still raises the underlying Guzzle exception, which is the existing documented behaviour and is covered by the pre-existing tests. README updated to draw the distinction.


Generated by Claude Code

@razor-x
razor-x force-pushed the claude/php-audit-uoa7nb-m3-unexpected-response branch from bcb65c6 to fce92c7 Compare August 19, 2026 21:30
Generated methods read the resource straight off the decoded envelope,
with no guard on any of the unwrap sites. A 200 whose body had been
rewritten or truncated on the way back, by a proxy, a gateway
maintenance page carrying a JSON content type, or a load balancer, left
the read as null and the failure surfaced from the method's return type:

  TypeError: DevicesClient::get(): Return value must be of type Device,
  null returned

A list endpoint got array_map(): Argument #2 must be of type array. Both
are an Error rather than an Exception, so neither is caught by
catch (\Exception) nor by catch (SeamException), and neither says what
was wrong with the response.

Add InvalidResponseError, a SeamException, and read the envelope through
Body::read and Body::read_list, which raise it naming the endpoint and
the key. Keeping the guard in one place leaves the generated call sites
a single call rather than a block repeated at every one.

The action attempt poll read the same way and raised a bare
UnexpectedValueException, which was equally invisible to a Seam catch
block. It now raises the same error.

The malformed-response tests covered only 500s, which never reach the
unwrap. Add the malformed 200 cases: a missing key, the wrong key, a
body that is not an object, an empty body, unparseable JSON, an HTML
gateway page, a list key holding something that is not a list, and a
malformed poll response.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HH3wdHh4Y6Wjyc5uHwk5iG
@razor-x
razor-x force-pushed the claude/php-audit-uoa7nb-m3-unexpected-response branch from fce92c7 to fae0706 Compare August 19, 2026 22:25
@razor-x
razor-x merged commit 206939e into beta Aug 19, 2026
15 checks passed
@razor-x
razor-x deleted the claude/php-audit-uoa7nb-m3-unexpected-response branch August 19, 2026 22:34
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