Skip to content

Commit 38dc6ab

Browse files
committed
fix: Raise a Seam error for a success response that is malformed
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y1RzepycXEYA3LStfjt8cY
1 parent f0ae5cb commit 38dc6ab

45 files changed

Lines changed: 1118 additions & 244 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

codegen/layouts/partials/route-method.hbs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,16 +24,16 @@
2424

2525
return {{#if isAsync}}await resolve_action_attempt_async{{else}}resolve_action_attempt{{/if}}(
2626
client=self.client,
27-
action_attempt=action_attempt_from_dict(res["action_attempt"]),
27+
action_attempt=action_attempt_from_dict(unwrap(res, "action_attempt", "{{path}}")),
2828
wait_for_action_attempt=wait_for_action_attempt
2929
)
3030
{{else if (eq returnType "None")}}
3131

3232
return None
3333
{{else if (isListType returnType)}}
3434

35-
return [{{fromDict (listItemType returnType)}}(item) for item in res{{#each returnPath}}["{{this}}"]{{/each}}]
35+
return [{{fromDict (listItemType returnType)}}(item) for item in unwrap_list(res, "{{returnPath.[0]}}", "{{path}}")]
3636
{{else}}
3737

38-
return {{fromDict returnType}}(res{{#each returnPath}}["{{this}}"]{{/each}})
38+
return {{fromDict returnType}}(unwrap(res, "{{returnPath.[0]}}", "{{path}}"))
3939
{{/if}}

codegen/layouts/route.hbs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,12 @@ from .{{module}} import {{abstractClassName}}, {{className}}, {{asyncAbstractCla
1414
{{#if importResolveActionAttempt}}
1515
from ..modules.action_attempts import resolve_action_attempt, resolve_action_attempt_async
1616
{{/if}}
17+
{{#if importUnwrap}}
18+
from ..response import unwrap
19+
{{/if}}
20+
{{#if importUnwrapList}}
21+
from ..response import unwrap_list
22+
{{/if}}
1723

1824

1925
{{> abstract-route-class abstractClass}}

codegen/lib/layouts/route.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,8 @@ export interface RouteLayoutContext {
6161
}>
6262
importResolveActionAttempt: boolean
6363
importNull: boolean
64+
importUnwrap: boolean
65+
importUnwrapList: boolean
6466
methods: MethodLayoutContext[]
6567
}
6668

@@ -130,6 +132,16 @@ export const setRouteLayoutContext = (cls: ClassModel): RouteLayoutContext => {
130132
params.some(({ isNullable }) => isNullable),
131133
)
132134

135+
const importUnwrap = methods.some(
136+
({ returnPath, returnType }) =>
137+
returnPath.length > 0 && !returnType.startsWith('List['),
138+
)
139+
140+
const importUnwrapList = methods.some(
141+
({ returnPath, returnType }) =>
142+
returnPath.length > 0 && returnType.startsWith('List['),
143+
)
144+
133145
const showPass =
134146
cls.methods.length === 0 && cls.childClassIdentifiers.length === 0
135147

@@ -172,6 +184,8 @@ export const setRouteLayoutContext = (cls: ClassModel): RouteLayoutContext => {
172184
})),
173185
importResolveActionAttempt,
174186
importNull,
187+
importUnwrap,
188+
importUnwrapList,
175189
methods,
176190
}
177191
}

seam/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
from .exceptions import (
99
SeamError,
1010
SeamHttpApiError,
11+
SeamHttpInvalidResponseError,
1112
SeamHttpUnauthorizedError,
1213
SeamHttpInvalidInputError,
1314
SeamValidationError,

seam/client.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
from collections.abc import Mapping
2+
from json import JSONDecodeError
23
from typing import Any, Dict, Optional
34
from importlib.metadata import version
45
import abc
@@ -68,7 +69,13 @@ def _handle_response(self, response: Response):
6869
self._handle_error_response(response)
6970

7071
if "application/json" in response.headers.get("content-type", ""):
71-
return response.json()
72+
try:
73+
return response.json()
74+
except JSONDecodeError:
75+
# A body that lies about its content type is handed on as
76+
# text, so readers report an invalid response instead of
77+
# leaking a decode error.
78+
return response.text
7279

7380
return response.text
7481

seam/exceptions.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,36 @@ class SeamError(Exception):
1616

1717

1818
# HTTP
19+
class SeamHttpInvalidResponseError(SeamError):
20+
"""
21+
Exception raised when a success response from the Seam API has an
22+
unexpected shape, e.g., a proxy rewrote the body or the expected
23+
response key is missing.
24+
25+
:ivar path: The request path that produced the response
26+
:vartype path: str
27+
:ivar response_key: The response key the SDK expected to read
28+
:vartype response_key: str
29+
"""
30+
31+
def __init__(self, path: str, response_key: str, reason: str):
32+
"""
33+
:param path: The request path that produced the response
34+
:type path: str
35+
:param response_key: The response key the SDK expected to read
36+
:type response_key: str
37+
:param reason: Description of how the response diverged
38+
:type reason: str
39+
"""
40+
41+
super().__init__(
42+
f"Seam returned an invalid response for {path}: "
43+
f'expected "{response_key}", {reason}'
44+
)
45+
self.path = path
46+
self.response_key = response_key
47+
48+
1949
class SeamHttpApiError(SeamError):
2050
"""
2151
Base exception for Seam HTTP API errors.

seam/modules/action_attempts.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from ..exceptions import SeamActionAttemptFailedError, SeamActionAttemptTimeoutError
77
from ..options import SeamInvalidOptionsError
88
from ..resources import ActionAttempt, SuccessActionAttempt, action_attempt_from_dict
9+
from ..response import unwrap
910

1011
TIMEOUT = 5.0
1112
POLLING_INTERVAL = 0.5
@@ -72,7 +73,9 @@ def get_action_attempt(client: SeamHttpClient, action_attempt_id: str) -> Action
7273
"/action_attempts/get", params={"action_attempt_id": action_attempt_id}
7374
)
7475

75-
return action_attempt_from_dict(res["action_attempt"])
76+
return action_attempt_from_dict(
77+
unwrap(res, "action_attempt", "/action_attempts/get")
78+
)
7679

7780

7881
def poll_until_ready(
@@ -142,7 +145,9 @@ async def get_action_attempt_async(
142145
"/action_attempts/get", params={"action_attempt_id": action_attempt_id}
143146
)
144147

145-
return action_attempt_from_dict(res["action_attempt"])
148+
return action_attempt_from_dict(
149+
unwrap(res, "action_attempt", "/action_attempts/get")
150+
)
146151

147152

148153
async def poll_until_ready_async(

seam/response.py

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
"""Read success response payloads defensively.
2+
3+
A 2xx response with an unexpected shape, e.g., a proxy rewrote the body or
4+
the response key was renamed, raises the SDK's own error instead of leaking
5+
a bare KeyError or TypeError from inside a generated route method.
6+
"""
7+
8+
from typing import Any, Dict, List
9+
10+
from .exceptions import SeamHttpInvalidResponseError
11+
12+
13+
def _read_response_key(res: Any, response_key: str, path: str) -> Any:
14+
if not isinstance(res, dict):
15+
raise SeamHttpInvalidResponseError(
16+
path,
17+
response_key,
18+
f"got {type(res).__name__} instead of a response object",
19+
)
20+
21+
if response_key not in res:
22+
raise SeamHttpInvalidResponseError(
23+
path, response_key, "which the response does not contain"
24+
)
25+
26+
return res[response_key]
27+
28+
29+
def unwrap(res: Any, response_key: str, path: str) -> Dict[str, Any]:
30+
"""Read an object under the response key, or raise for a malformed response."""
31+
32+
value = _read_response_key(res, response_key, path)
33+
34+
if not isinstance(value, dict):
35+
raise SeamHttpInvalidResponseError(
36+
path, response_key, f"got {type(value).__name__} instead of an object"
37+
)
38+
39+
return value
40+
41+
42+
def unwrap_list(res: Any, response_key: str, path: str) -> List[Any]:
43+
"""Read a list under the response key, or raise for a malformed response."""
44+
45+
value = _read_response_key(res, response_key, path)
46+
47+
if not isinstance(value, list):
48+
raise SeamHttpInvalidResponseError(
49+
path, response_key, f"got {type(value).__name__} instead of a list"
50+
)
51+
52+
return value

seam/routes/access_codes.py

Lines changed: 38 additions & 12 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

seam/routes/access_codes_simulate.py

Lines changed: 15 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)