Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
71 changes: 34 additions & 37 deletions src/lib/resolve-action-attempt.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { SeamHttpInvalidOptionsError } from './options.js'
import type { ActionAttempt } from './resources/action-attempt.js'

/**
Expand Down Expand Up @@ -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 <T extends ActionAttempt>(
actionAttempt: T,
actionAttempts: ActionAttemptsClient,
{ timeout = 10_000, pollingInterval = 1_000 }: ResolveActionAttemptOptions,
): Promise<SucceededActionAttempt<T>> => {
let timeoutRef
const timeoutPromise = new Promise<SucceededActionAttempt<T>>(
(_resolve, reject) => {
timeoutRef = globalThis.setTimeout(() => {
reject(new SeamActionAttemptTimeoutError<T>(actionAttempt, timeout))
}, timeout)
},
)

try {
return await Promise.race([
pollActionAttempt<T>(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 <T extends ActionAttempt>(
actionAttempt: T,
actionAttempts: ActionAttemptsClient,
options: Pick<ResolveActionAttemptOptions, 'pollingInterval'>,
): Promise<SucceededActionAttempt<T>> => {
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<T>(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
}
}

/**
Expand Down Expand Up @@ -146,7 +143,7 @@ export class SeamActionAttemptTimeoutError<
> extends SeamActionAttemptError<T> {
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
Expand Down
130 changes: 130 additions & 0 deletions test/seam/connect/wait-for-action-attempt.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
SeamActionAttemptFailedError,
SeamActionAttemptTimeoutError,
SeamHttp,
SeamHttpInvalidOptionsError,
} from '@seamapi/http/connect'

test('waitForActionAttempt: waits for pending action attempt', async (t) => {
Expand Down Expand Up @@ -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)

Expand Down
Loading