From b9d58eb355a792af5477205617e6beadf2c68a30 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 24 Aug 2026 19:18:09 +0000 Subject: [PATCH 1/2] fix: Stop polling an action attempt once the timeout passes Waiting for an action attempt raced an unbounded poll loop against a timer. The caller received SeamActionAttemptTimeoutError, but the losing poll loop was never cancelled and kept polling once per interval forever. The race also let a timeout shorter than the polling interval reject without ever polling, and a pollingInterval of zero polled unthrottled for the entire timeout window. Poll iteratively against a deadline instead: sleep the lesser of the polling interval and the remaining budget, poll, and time out only once the deadline has passed, so every wait polls at least once and polling stops with the wait. Validate the options up front, raising SeamHttpInvalidOptionsError for a negative timeout or a pollingInterval not greater than zero, and fix the timeout error message which read 'waiting for action action attempt'. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01B8xeJm2Hd923k8uo6eoFd2 --- README.md | 5 + src/lib/resolve-action-attempt.ts | 75 +++++----- .../connect/wait-for-action-attempt.test.ts | 130 ++++++++++++++++++ 3 files changed, 173 insertions(+), 37 deletions(-) diff --git a/README.md b/README.md index 59a9fd52..523cd2da 100644 --- a/README.md +++ b/README.md @@ -193,6 +193,11 @@ When the `waitForActionAttempt` option is enabled, the SDK: - Polls the action attempt up to the `timeout` at the `pollingInterval` (both in milliseconds). + Polling stops as soon as the `timeout` passes, + and every wait polls at least once, + even when the `timeout` is shorter than the `pollingInterval`. + The `timeout` must not be negative, + and the `pollingInterval` must be greater than zero. - Resolves with a fresh copy of the successful action attempt. - Rejects with a `SeamActionAttemptFailedError` if the action attempt is unsuccessful. - Rejects with a `SeamActionAttemptTimeoutError` if the action attempt is still pending when the `timeout` is reached. diff --git a/src/lib/resolve-action-attempt.ts b/src/lib/resolve-action-attempt.ts index e01607be..cc00a46c 100644 --- a/src/lib/resolve-action-attempt.ts +++ b/src/lib/resolve-action-attempt.ts @@ -1,3 +1,4 @@ +import { SeamHttpInvalidOptionsError } from './options.js' import type { ActionAttempt } from './resources/action-attempt.js' /** @@ -25,59 +26,59 @@ export interface ResolveActionAttemptOptions { /** * Polls a pending action attempt until it succeeds, fails, or times out. * + * Polling stops as soon as the deadline set by the `timeout` option passes, + * and every wait polls at least once, + * even when the `timeout` is shorter than the `pollingInterval`. + * * @returns The succeeded action attempt. * @throws {@link SeamActionAttemptFailedError} if the action attempt fails. * @throws {@link SeamActionAttemptTimeoutError} if the action attempt * does not resolve within the timeout. + * @throws {@link SeamHttpInvalidOptionsError} if the timeout is negative + * or the pollingInterval is not greater than zero. */ export const resolveActionAttempt = async ( actionAttempt: T, actionAttempts: ActionAttemptsClient, { timeout = 10_000, pollingInterval = 1_000 }: ResolveActionAttemptOptions, ): Promise> => { - let timeoutRef - const timeoutPromise = new Promise>( - (_resolve, reject) => { - timeoutRef = globalThis.setTimeout(() => { - reject(new SeamActionAttemptTimeoutError(actionAttempt, timeout)) - }, timeout) - }, - ) - - try { - return await Promise.race([ - pollActionAttempt(actionAttempt, actionAttempts, { pollingInterval }), - timeoutPromise, - ]) - } finally { - if (timeoutRef != null) globalThis.clearTimeout(timeoutRef) + if (Number.isNaN(timeout) || timeout < 0) { + throw new SeamHttpInvalidOptionsError( + `The timeout option must not be negative, got ${timeout}`, + ) } -} -const pollActionAttempt = async ( - actionAttempt: T, - actionAttempts: ActionAttemptsClient, - options: Pick, -): Promise> => { - if (isSuccessfulActionAttempt(actionAttempt)) { - return actionAttempt + if (Number.isNaN(pollingInterval) || pollingInterval <= 0) { + throw new SeamHttpInvalidOptionsError( + `The pollingInterval option must be greater than zero, got ${pollingInterval}`, + ) } - if (isFailedActionAttempt(actionAttempt)) { - throw new SeamActionAttemptFailedError(actionAttempt) - } + const deadline = Date.now() + timeout + let currentActionAttempt = actionAttempt + + while (true) { + if (isSuccessfulActionAttempt(currentActionAttempt)) { + return currentActionAttempt + } - await new Promise((resolve) => setTimeout(resolve, options.pollingInterval)) + if (isFailedActionAttempt(currentActionAttempt)) { + throw new SeamActionAttemptFailedError(currentActionAttempt) + } - const nextActionAttempt = await actionAttempts.get({ - action_attempt_id: actionAttempt.action_attempt_id, - }) + const remaining = deadline - Date.now() + if (remaining <= 0) { + throw new SeamActionAttemptTimeoutError(currentActionAttempt, timeout) + } - return await pollActionAttempt( - nextActionAttempt as unknown as T, - actionAttempts, - options, - ) + await new Promise((resolve) => + setTimeout(resolve, Math.min(pollingInterval, remaining)), + ) + + currentActionAttempt = (await actionAttempts.get({ + action_attempt_id: currentActionAttempt.action_attempt_id, + })) as unknown as T + } } /** @@ -146,7 +147,7 @@ export class SeamActionAttemptTimeoutError< > extends SeamActionAttemptError { constructor(actionAttempt: T, timeout: number) { super( - `Timed out waiting for action action attempt after ${timeout}ms`, + `Timed out waiting for action attempt after ${timeout}ms`, actionAttempt, ) this.name = this.constructor.name diff --git a/test/seam/connect/wait-for-action-attempt.test.ts b/test/seam/connect/wait-for-action-attempt.test.ts index 8995b9da..ffa8e4ef 100644 --- a/test/seam/connect/wait-for-action-attempt.test.ts +++ b/test/seam/connect/wait-for-action-attempt.test.ts @@ -5,6 +5,7 @@ import { SeamActionAttemptFailedError, SeamActionAttemptTimeoutError, SeamHttp, + SeamHttpInvalidOptionsError, } from '@seamapi/http/connect' test('waitForActionAttempt: waits for pending action attempt', async (t) => { @@ -204,6 +205,135 @@ test('waitForActionAttempt: times out if waiting for polling interval', async (t t.deepEqual(err?.actionAttempt, actionAttempt) }) +test('waitForActionAttempt: stops polling after the timeout', async (t) => { + const { seed, endpoint } = await getTestServer(t) + + const seam = SeamHttp.fromApiKey(seed.seam_apikey1_token, { + endpoint, + waitForActionAttempt: false, + }) + + const actionAttempt = await seam.locks.unlockDoor({ + device_id: seed.august_device_1, + }) + + await seam.client.post('/_fake/update_action_attempt', { + action_attempt_id: actionAttempt.action_attempt_id, + status: 'pending', + }) + + let pollCount = 0 + seam.client.interceptors.request.use((config) => { + if (config.url === '/action_attempts/get') pollCount++ + return config + }) + + const err = await t.throwsAsync( + async () => + await seam.actionAttempts.get( + { action_attempt_id: actionAttempt.action_attempt_id }, + { waitForActionAttempt: { timeout: 300, pollingInterval: 100 } }, + ), + { instanceOf: SeamActionAttemptTimeoutError }, + ) + + t.regex(err?.message ?? '', /Timed out waiting for action attempt/) + + const pollCountAtTimeout = pollCount + t.true(pollCountAtTimeout > 0) + + await new Promise((resolve) => setTimeout(resolve, 500)) + t.is(pollCount, pollCountAtTimeout) +}) + +test('waitForActionAttempt: polls at least once when the timeout is shorter than the pollingInterval', async (t) => { + const { seed, endpoint } = await getTestServer(t) + + const seam = SeamHttp.fromApiKey(seed.seam_apikey1_token, { + endpoint, + waitForActionAttempt: false, + }) + + const actionAttempt = await seam.locks.unlockDoor({ + device_id: seed.august_device_1, + }) + + await seam.client.post('/_fake/update_action_attempt', { + action_attempt_id: actionAttempt.action_attempt_id, + status: 'pending', + }) + + let requestCount = 0 + seam.client.interceptors.request.use((config) => { + if (config.url === '/action_attempts/get') requestCount++ + return config + }) + + const start = Date.now() + await t.throwsAsync( + async () => + await seam.actionAttempts.get( + { action_attempt_id: actionAttempt.action_attempt_id }, + { waitForActionAttempt: { timeout: 300, pollingInterval: 60_000 } }, + ), + { instanceOf: SeamActionAttemptTimeoutError }, + ) + + // The initial request plus exactly one poll before the deadline. + t.is(requestCount, 2) + t.true(Date.now() - start < 10_000) +}) + +test('waitForActionAttempt: rejects a negative timeout', async (t) => { + const { seed, endpoint } = await getTestServer(t) + + const seam = SeamHttp.fromApiKey(seed.seam_apikey1_token, { + endpoint, + waitForActionAttempt: false, + }) + + const actionAttempt = await seam.locks.unlockDoor({ + device_id: seed.august_device_1, + }) + + await t.throwsAsync( + async () => + await seam.actionAttempts.get( + { action_attempt_id: actionAttempt.action_attempt_id }, + { waitForActionAttempt: { timeout: -1 } }, + ), + { + instanceOf: SeamHttpInvalidOptionsError, + message: /timeout option must not be negative/, + }, + ) +}) + +test('waitForActionAttempt: rejects a pollingInterval of zero', async (t) => { + const { seed, endpoint } = await getTestServer(t) + + const seam = SeamHttp.fromApiKey(seed.seam_apikey1_token, { + endpoint, + waitForActionAttempt: false, + }) + + const actionAttempt = await seam.locks.unlockDoor({ + device_id: seed.august_device_1, + }) + + await t.throwsAsync( + async () => + await seam.actionAttempts.get( + { action_attempt_id: actionAttempt.action_attempt_id }, + { waitForActionAttempt: { pollingInterval: 0 } }, + ), + { + instanceOf: SeamHttpInvalidOptionsError, + message: /pollingInterval option must be greater than zero/, + }, + ) +}) + test('waitForActionAttempt: waits directly on returned action attempt', async (t) => { const { seed, endpoint } = await getTestServer(t) From d7bdc8817fe77dad5e5413356af2207a7a670f74 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 24 Aug 2026 23:28:33 +0000 Subject: [PATCH 2/2] docs: Trim the resolveActionAttempt doc Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01B8xeJm2Hd923k8uo6eoFd2 --- src/lib/resolve-action-attempt.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/lib/resolve-action-attempt.ts b/src/lib/resolve-action-attempt.ts index cc00a46c..5e137011 100644 --- a/src/lib/resolve-action-attempt.ts +++ b/src/lib/resolve-action-attempt.ts @@ -26,10 +26,6 @@ export interface ResolveActionAttemptOptions { /** * Polls a pending action attempt until it succeeds, fails, or times out. * - * Polling stops as soon as the deadline set by the `timeout` option passes, - * and every wait polls at least once, - * even when the `timeout` is shorter than the `pollingInterval`. - * * @returns The succeeded action attempt. * @throws {@link SeamActionAttemptFailedError} if the action attempt fails. * @throws {@link SeamActionAttemptTimeoutError} if the action attempt