From 84303790ed79ec47978ee50ad3595b442f1d77f5 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 24 Aug 2026 19:22:46 +0000 Subject: [PATCH] fix: Raise a Seam error for a success response that is malformed A 200 response with an unexpected envelope, such as a proxy rewrite or a gateway page with a JSON content type, escaped the SDK error hierarchy entirely: reading a missing response key produced undefined, which surfaced as a bare TypeError from the action attempt poller or as undefined response data. The paginator threw plain Error objects for the same class of failure. Centralize the response unwrap in readResponseData and raise the new SeamHttpInvalidResponseError, which names the endpoint path and the expected response key. The paginator uses the same guard for the list data and the pagination object. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01B8xeJm2Hd923k8uo6eoFd2 --- src/lib/seam-http-error.ts | 35 +++++ src/lib/seam-http-request.ts | 39 +++++- src/lib/seam-paginator.ts | 44 ++++--- test/seam/connect/invalid-response.test.ts | 142 +++++++++++++++++++++ 4 files changed, 243 insertions(+), 17 deletions(-) create mode 100644 test/seam/connect/invalid-response.test.ts diff --git a/src/lib/seam-http-error.ts b/src/lib/seam-http-error.ts index e16f3c2e..e6450e3a 100644 --- a/src/lib/seam-http-error.ts +++ b/src/lib/seam-http-error.ts @@ -103,3 +103,38 @@ export const isSeamHttpInvalidInputError = ( ): error is SeamHttpInvalidInputError => { return error instanceof SeamHttpInvalidInputError } + +/** + * Error thrown when the Seam API returns a success response + * with an unexpected shape, + * e.g., a response missing the expected response key. + */ +export class SeamHttpInvalidResponseError extends Error { + /** + * Path of the endpoint that returned the invalid response. + */ + path: string + + /** + * Key expected to contain the response data. + */ + responseKey: string + + constructor(path: string, responseKey: string, reason: string) { + super( + `Seam returned an invalid response for ${path}: expected "${responseKey}", ${reason}`, + ) + this.name = this.constructor.name + this.path = path + this.responseKey = responseKey + } +} + +/** + * Returns true if the error is a {@link SeamHttpInvalidResponseError}. + */ +export const isSeamHttpInvalidResponseError = ( + error: unknown, +): error is SeamHttpInvalidResponseError => { + return error instanceof SeamHttpInvalidResponseError +} diff --git a/src/lib/seam-http-request.ts b/src/lib/seam-http-request.ts index 719de08d..9649c5e7 100644 --- a/src/lib/seam-http-request.ts +++ b/src/lib/seam-http-request.ts @@ -8,6 +8,7 @@ import { resolveActionAttempt, } from './resolve-action-attempt.js' import type { ActionAttempt } from './resources/action-attempt.js' +import { SeamHttpInvalidResponseError } from './seam-http-error.js' import { serializeUrlSearchParams } from './url-search-params-serializer.js' interface SeamHttpRequestParent { @@ -132,7 +133,11 @@ export class SeamHttpRequest< return undefined as Response } - const data = response[this.responseKey] as unknown as Response + const data = readResponseData( + response, + this.responseKey, + this.pathname, + ) as Response if (this.responseKey === 'action_attempt') { const waitForActionAttempt = @@ -222,6 +227,38 @@ export class SeamHttpRequest< } } +/** + * Reads the response data at the response key, + * throwing a {@link SeamHttpInvalidResponseError} for a success response + * that is not an object or does not contain the response key. + */ +export const readResponseData = < + TResponse, + TResponseKey extends keyof TResponse, +>( + response: TResponse, + responseKey: TResponseKey, + path: string, +): TResponse[TResponseKey] => { + if (response == null || typeof response !== 'object') { + throw new SeamHttpInvalidResponseError( + path, + String(responseKey), + `got ${response === null ? 'null' : typeof response} instead of a response object`, + ) + } + + if (!(responseKey in response)) { + throw new SeamHttpInvalidResponseError( + path, + String(responseKey), + 'which the response does not contain', + ) + } + + return response[responseKey] +} + const getUrlPrefix = (input: string): string => { if (canParseUrl(input)) { const url = new URL(input).toString() diff --git a/src/lib/seam-paginator.ts b/src/lib/seam-paginator.ts index 0741f46f..9c84bd5a 100644 --- a/src/lib/seam-paginator.ts +++ b/src/lib/seam-paginator.ts @@ -1,6 +1,7 @@ import type { Client } from './client.js' import type { SeamHttpRequestOptions } from './options.js' -import { SeamHttpRequest } from './seam-http-request.js' +import { SeamHttpInvalidResponseError } from './seam-http-error.js' +import { readResponseData, SeamHttpRequest } from './seam-http-request.js' interface SeamPaginatorParent { readonly client: Client @@ -96,27 +97,38 @@ export class SeamPaginator< }) const response = await request.fetchResponse() - const data = response[responseKey] + const data = readResponseData(response, responseKey, request.pathname) - const paginationData = - response != null && - typeof response === 'object' && - 'pagination' in response - ? (response.pagination as PaginationData) - : null - - const pagination: Pagination = { - hasNextPage: paginationData?.has_next_page ?? false, - nextPageCursor: paginationData?.next_page_cursor ?? null, - nextPageUrl: paginationData?.next_page_url ?? null, + if (!Array.isArray(data)) { + throw new SeamHttpInvalidResponseError( + request.pathname, + String(responseKey), + `got ${data === null ? 'null' : typeof data} instead of a list`, + ) } - if (!Array.isArray(data)) { - throw new Error( - `Expected an array response for ${String(responseKey)} but got ${String(typeof data)}`, + const paginationData = readResponseData( + response as { pagination: unknown }, + 'pagination', + request.pathname, + ) + + if (paginationData === null || typeof paginationData !== 'object') { + throw new SeamHttpInvalidResponseError( + request.pathname, + 'pagination', + `got ${paginationData === null ? 'null' : typeof paginationData} instead of a pagination object`, ) } + const paginationResponse = paginationData as PaginationData + + const pagination: Pagination = { + hasNextPage: paginationResponse.has_next_page ?? false, + nextPageCursor: paginationResponse.next_page_cursor ?? null, + nextPageUrl: paginationResponse.next_page_url ?? null, + } + return [ data as EnsureReadonlyArray, pagination, diff --git a/test/seam/connect/invalid-response.test.ts b/test/seam/connect/invalid-response.test.ts new file mode 100644 index 00000000..abf7e0d9 --- /dev/null +++ b/test/seam/connect/invalid-response.test.ts @@ -0,0 +1,142 @@ +import test from 'ava' +import { getTestServer } from 'fixtures/seam/connect/api.js' +import nock from 'nock' + +import { + isSeamHttpInvalidResponseError, + SeamHttp, + SeamHttpInvalidResponseError, +} from '@seamapi/http/connect' + +const jsonHeaders = { 'Content-Type': 'application/json' } + +test('SeamHttpRequest: throws for a success response missing the response key', async (t) => { + const { seed, endpoint } = await getTestServer(t) + const seam = SeamHttp.fromApiKey(seed.seam_apikey1_token, { endpoint }) + + nock(endpoint).get('/devices/get').query(true).reply(200, {}) + + const err = await t.throwsAsync( + async () => await seam.devices.get({ device_id: seed.august_device_1 }), + { + instanceOf: SeamHttpInvalidResponseError, + message: + 'Seam returned an invalid response for /devices/get: expected "device", which the response does not contain', + }, + ) + + t.true(isSeamHttpInvalidResponseError(err)) + t.is(err?.path, '/devices/get') + t.is(err?.responseKey, 'device') +}) + +test('SeamHttpRequest: throws for a success response that is not an object', async (t) => { + const { seed, endpoint } = await getTestServer(t) + const seam = SeamHttp.fromApiKey(seed.seam_apikey1_token, { endpoint }) + + nock(endpoint) + .get('/devices/get') + .query(true) + .reply(200, JSON.stringify('device'), jsonHeaders) + + await t.throwsAsync( + async () => await seam.devices.get({ device_id: seed.august_device_1 }), + { + instanceOf: SeamHttpInvalidResponseError, + message: + 'Seam returned an invalid response for /devices/get: expected "device", got string instead of a response object', + }, + ) +}) + +test('SeamHttpRequest: throws for a null success response', async (t) => { + const { seed, endpoint } = await getTestServer(t) + const seam = SeamHttp.fromApiKey(seed.seam_apikey1_token, { endpoint }) + + nock(endpoint) + .get('/devices/get') + .query(true) + .reply(200, JSON.stringify(null), jsonHeaders) + + await t.throwsAsync( + async () => await seam.devices.get({ device_id: seed.august_device_1 }), + { + instanceOf: SeamHttpInvalidResponseError, + message: + 'Seam returned an invalid response for /devices/get: expected "device", got null instead of a response object', + }, + ) +}) + +test('SeamHttpRequest: throws for a malformed action attempt response', async (t) => { + const { seed, endpoint } = await getTestServer(t) + const seam = SeamHttp.fromApiKey(seed.seam_apikey1_token, { endpoint }) + + nock(endpoint).post('/locks/unlock_door').reply(200, {}) + + await t.throwsAsync( + async () => + await seam.locks.unlockDoor({ device_id: seed.august_device_1 }), + { + instanceOf: SeamHttpInvalidResponseError, + message: + 'Seam returned an invalid response for /locks/unlock_door: expected "action_attempt", which the response does not contain', + }, + ) +}) + +test('SeamPaginator: throws for a success response where the response key is not a list', async (t) => { + const { seed, endpoint } = await getTestServer(t) + const seam = SeamHttp.fromApiKey(seed.seam_apikey1_token, { endpoint }) + + nock(endpoint) + .get('/devices/list') + .reply(200, { + devices: 'device-1', + pagination: { + has_next_page: false, + next_page_cursor: null, + next_page_url: null, + }, + }) + + const pages = seam.createPaginator(seam.devices.list()) + + await t.throwsAsync(async () => await pages.firstPage(), { + instanceOf: SeamHttpInvalidResponseError, + message: + 'Seam returned an invalid response for /devices/list: expected "devices", got string instead of a list', + }) +}) + +test('SeamPaginator: throws for a success response missing the pagination object', async (t) => { + const { seed, endpoint } = await getTestServer(t) + const seam = SeamHttp.fromApiKey(seed.seam_apikey1_token, { endpoint }) + + nock(endpoint).get('/devices/list').reply(200, { devices: [] }) + + const pages = seam.createPaginator(seam.devices.list()) + + await t.throwsAsync(async () => await pages.firstPage(), { + instanceOf: SeamHttpInvalidResponseError, + message: + 'Seam returned an invalid response for /devices/list: expected "pagination", which the response does not contain', + }) +}) + +test('SeamPaginator: throws for a success response with a non-object pagination value', async (t) => { + const { seed, endpoint } = await getTestServer(t) + const seam = SeamHttp.fromApiKey(seed.seam_apikey1_token, { endpoint }) + + nock(endpoint) + .get('/devices/list') + .reply(200, { devices: [], pagination: 'none' }) + + const pages = seam.createPaginator(seam.devices.list()) + + await t.throwsAsync(async () => await pages.firstPage(), { + instanceOf: SeamHttpInvalidResponseError, + message: + 'Seam returned an invalid response for /devices/list: expected "pagination", got string instead of a pagination object', + }) +})