diff --git a/README.md b/README.md index 5fd6a590..853ebcc7 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..5e137011 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' /** @@ -29,55 +30,51 @@ export interface ResolveActionAttemptOptions { * @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 +143,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)