From 9b56908c87a9c6f136709687f5040879ee9d83c1 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 24 Aug 2026 19:08:59 +0000 Subject: [PATCH] fix: Send a SeamHttpRequest at most once A SeamHttpRequest re-executed the HTTP request every time it was awaited or given a then, catch, or finally callback. Any defensive logging idiom, Promise.all with the same request, or awaiting a stored request twice silently repeated the request, duplicating writes such as door unlocks or access code creation. Memoize the promises from execute and fetchResponse so the request is sent at most once and every consumer observes the first execution. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01B8xeJm2Hd923k8uo6eoFd2 --- README.md | 6 ++ src/lib/seam-http-request.ts | 29 ++++++ test/seam/connect/seam-http-request.test.ts | 107 ++++++++++++++++++++ 3 files changed, 142 insertions(+) diff --git a/README.md b/README.md index 59a9fd52..e66d143c 100644 --- a/README.md +++ b/README.md @@ -537,6 +537,12 @@ console.log(`${request.method} ${request.url}`, JSON.stringify(request.body)) const devices = await request.execute() ``` +A `SeamHttpRequest` is sent at most once. +Awaiting the same request again, +or calling `execute`, `then`, `catch`, or `finally` more than once, +always returns the result of the first execution +and never repeats the HTTP request. + #### Serializing URL search params The Seam API parses URL search params as complex types. diff --git a/src/lib/seam-http-request.ts b/src/lib/seam-http-request.ts index 719de08d..b5be30ad 100644 --- a/src/lib/seam-http-request.ts +++ b/src/lib/seam-http-request.ts @@ -36,6 +36,11 @@ interface SeamHttpRequestConfig { * The request is sent once `execute` is called, * or when the request is awaited like a Promise, * e.g., with `await`, `then`, `catch`, or `finally`. + * The request is sent at most once: + * awaiting the same SeamHttpRequest again, + * or calling `execute`, `then`, `catch`, or `finally` more than once, + * always returns the result of the first execution + * and never repeats the HTTP request. * When the response contains an action attempt, * awaiting the request also waits for the action attempt to resolve * according to the `waitForActionAttempt` option. @@ -54,6 +59,12 @@ export class SeamHttpRequest< readonly #parent: SeamHttpRequestParent readonly #config: SeamHttpRequestConfig + #executePromise: Promise< + TResponseKey extends keyof TResponse ? TResponse[TResponseKey] : undefined + > | null = null + + #fetchResponsePromise: Promise | null = null + constructor( parent: SeamHttpRequestParent, config: SeamHttpRequestConfig, @@ -118,9 +129,19 @@ export class SeamHttpRequest< * If the response contains an action attempt, * waits for the action attempt to resolve * according to the `waitForActionAttempt` option. + * The request is sent at most once: + * calling this method again returns the result of the first call + * and never repeats the HTTP request. */ async execute(): Promise< TResponseKey extends keyof TResponse ? TResponse[TResponseKey] : undefined + > { + this.#executePromise ??= this.#execute() + return await this.#executePromise + } + + async #execute(): Promise< + TResponseKey extends keyof TResponse ? TResponse[TResponseKey] : undefined > { const response = await this.fetchResponse() @@ -160,8 +181,16 @@ export class SeamHttpRequest< /** * Sends the request and returns the entire response body * without waiting for any action attempt to resolve. + * The request is sent at most once: + * calling this method again returns the result of the first call + * and never repeats the HTTP request. */ async fetchResponse(): Promise { + this.#fetchResponsePromise ??= this.#fetchResponse() + return await this.#fetchResponsePromise + } + + async #fetchResponse(): Promise { assertValidRequestParameters( this.#config.parameters, this.pathname, diff --git a/test/seam/connect/seam-http-request.test.ts b/test/seam/connect/seam-http-request.test.ts index 00e69b5d..2a79f166 100644 --- a/test/seam/connect/seam-http-request.test.ts +++ b/test/seam/connect/seam-http-request.test.ts @@ -172,6 +172,113 @@ test.serial( }, ) +test('SeamHttpRequest: sends the request at most once when awaited more than once', async (t) => { + const { seed, endpoint } = await getTestServer(t) + const seam = SeamHttp.fromApiKey(seed.seam_apikey1_token, { endpoint }) + + let requestCount = 0 + seam.client.interceptors.request.use((config) => { + requestCount++ + return config + }) + + const request = seam.devices.get({ device_id: seed.august_device_1 }) + + const device = await request + const deviceAgain = await request + const [deviceOnceMore] = await Promise.all([request, request]) + + t.is(requestCount, 1) + t.is(device.device_id, seed.august_device_1) + t.is(deviceAgain.device_id, seed.august_device_1) + t.is(deviceOnceMore?.device_id, seed.august_device_1) +}) + +test('SeamHttpRequest: catch does not send the request again', async (t) => { + const { seed, endpoint } = await getTestServer(t) + const seam = SeamHttp.fromApiKey(seed.seam_apikey1_token, { endpoint }) + + let requestCount = 0 + seam.client.interceptors.request.use((config) => { + requestCount++ + return config + }) + + const request = seam.devices.get({ device_id: seed.august_device_1 }) + + const device = await request + await request.catch(() => { + t.fail('should not reject') + }) + + t.is(requestCount, 1) + t.is(device.device_id, seed.august_device_1) +}) + +test('SeamHttpRequest: finally does not send the request again', async (t) => { + const { seed, endpoint } = await getTestServer(t) + const seam = SeamHttp.fromApiKey(seed.seam_apikey1_token, { endpoint }) + + let requestCount = 0 + seam.client.interceptors.request.use((config) => { + requestCount++ + return config + }) + + const request = seam.devices.get({ device_id: seed.august_device_1 }) + + const device = await request + const deviceAgain = await request.finally(() => {}) + + t.is(requestCount, 1) + t.is(device.device_id, seed.august_device_1) + t.is(deviceAgain.device_id, seed.august_device_1) +}) + +test('SeamHttpRequest: sends a write request at most once', async (t) => { + const { seed, endpoint } = await getTestServer(t) + const seam = SeamHttp.fromApiKey(seed.seam_apikey1_token, { + endpoint, + waitForActionAttempt: false, + }) + + let writeCount = 0 + seam.client.interceptors.request.use((config) => { + if (config.url === '/locks/unlock_door') writeCount++ + return config + }) + + const request = seam.locks.unlockDoor({ device_id: seed.august_device_1 }) + + const actionAttempt = await request + request.catch(() => { + t.fail('should not reject') + }) + const actionAttemptAgain = await request + + t.is(writeCount, 1) + t.is(actionAttempt.action_attempt_id, actionAttemptAgain.action_attempt_id) +}) + +test('SeamHttpRequest: a rejected request stays rejected when awaited again', async (t) => { + const { seed, endpoint } = await getTestServer(t) + const seam = SeamHttp.fromApiKey(seed.seam_apikey1_token, { endpoint }) + + let requestCount = 0 + seam.client.interceptors.request.use((config) => { + requestCount++ + return config + }) + + const request = seam.devices.get({ device_id: 'unknown-device-id' }) + + const err = await t.throwsAsync(async () => await request) + const errAgain = await t.throwsAsync(async () => await request) + + t.is(requestCount, 1) + t.is(err, errAgain) +}) + const toPlainUrlObject = (url: URL): Omit => { return { pathname: url.pathname,