Skip to content

Commit 21b85f7

Browse files
kvzclaude
andcommitted
feat: honor abort signal during awaitAssemblyCompletion polling
Previously, the abort signal passed to createAssembly was only honored during the initial HTTP POST and TUS uploads. Now it's also honored during the polling loop in awaitAssemblyCompletion, allowing users to cancel long-running assembly operations at any point. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 90cde44 commit 21b85f7

2 files changed

Lines changed: 54 additions & 3 deletions

File tree

src/Transloadit.ts

Lines changed: 32 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,11 @@ export interface AwaitAssemblyCompletionOptions {
102102
timeout?: number
103103
interval?: number
104104
startTimeMs?: number
105+
/**
106+
* Optional AbortSignal to cancel polling.
107+
* When aborted, the polling loop will stop and throw an AbortError.
108+
*/
109+
signal?: AbortSignal
105110
}
106111

107112
export interface SmartCDNUrlOptions {
@@ -339,6 +344,7 @@ export class Transloadit {
339344
timeout,
340345
onAssemblyProgress,
341346
startTimeMs,
347+
signal,
342348
})
343349
checkResult(awaitResult)
344350
return awaitResult
@@ -358,12 +364,18 @@ export class Transloadit {
358364
timeout,
359365
startTimeMs = getHrTimeMs(),
360366
interval = 1000,
367+
signal,
361368
}: AwaitAssemblyCompletionOptions = {},
362369
): Promise<AssemblyStatus> {
363370
assert.ok(assemblyId)
364371

365372
while (true) {
366-
const result = await this.getAssembly(assemblyId)
373+
// Check if aborted before making the request
374+
if (signal?.aborted) {
375+
throw signal.reason ?? new DOMException('Aborted', 'AbortError')
376+
}
377+
378+
const result = await this.getAssembly(assemblyId, { signal })
367379

368380
// If 'ok' is not in result, it implies a terminal state (e.g., error, completed, canceled).
369381
// If 'ok' is present, then we check if it's one of the non-terminal polling states.
@@ -391,7 +403,19 @@ export class Transloadit {
391403
if (timeout != null && nowMs - startTimeMs >= timeout) {
392404
throw new PollingTimeoutError('Polling timed out')
393405
}
394-
await new Promise((resolve) => setTimeout(resolve, interval))
406+
407+
// Make the sleep abortable
408+
await new Promise<void>((resolve, reject) => {
409+
const timeoutId = setTimeout(resolve, interval)
410+
signal?.addEventListener(
411+
'abort',
412+
() => {
413+
clearTimeout(timeoutId)
414+
reject(signal.reason ?? new DOMException('Aborted', 'AbortError'))
415+
},
416+
{ once: true },
417+
)
418+
})
395419
}
396420
}
397421

@@ -523,11 +547,16 @@ export class Transloadit {
523547
* Get an Assembly
524548
*
525549
* @param assemblyId the Assembly Id
550+
* @param options optional request options
526551
* @returns the retrieved Assembly
527552
*/
528-
async getAssembly(assemblyId: string): Promise<AssemblyStatus> {
553+
async getAssembly(
554+
assemblyId: string,
555+
options?: { signal?: AbortSignal },
556+
): Promise<AssemblyStatus> {
529557
const rawResult = await this._remoteJson<Record<string, unknown>, OptionalAuthParams>({
530558
urlSuffix: `/assemblies/${assemblyId}`,
559+
signal: options?.signal,
531560
})
532561

533562
const parsedResult = zodParseWithContext(assemblyStatusSchema, rawResult)

test/unit/mock-http.test.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,28 @@ describe('Mocked API tests', () => {
5454
scope.done()
5555
})
5656

57+
it('should honor abort signal during awaitAssemblyCompletion polling', async () => {
58+
const client = getLocalClient()
59+
60+
// Set up a mock that keeps returning ASSEMBLY_EXECUTING (never completes)
61+
const scope = nock('http://localhost')
62+
.get('/assemblies/1')
63+
.query(() => true)
64+
.reply(200, { ok: 'ASSEMBLY_EXECUTING', assembly_url: '', assembly_ssl_url: '' })
65+
.persist() // Keep responding with same status
66+
67+
const controller = new AbortController()
68+
69+
// Abort after 50ms
70+
setTimeout(() => controller.abort(), 50)
71+
72+
await expect(
73+
client.awaitAssemblyCompletion('1', { interval: 10, signal: controller.signal }),
74+
).rejects.toThrow(expect.objectContaining({ name: 'AbortError' }))
75+
76+
scope.persist(false)
77+
})
78+
5779
it('should handle aborted correctly', async () => {
5880
const client = getLocalClient()
5981

0 commit comments

Comments
 (0)