diff --git a/CHANGELOG.md b/CHANGELOG.md index 8f47204..6505500 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,13 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Fixed + +- Bound Calls `waitForResult` GET requests and poll sleeps by the remaining + `timeoutMs` budget, aborting in-flight polls when the deadline expires. + ## [1.0.1] - 2026-09-23 ### Fixed diff --git a/src/calls.ts b/src/calls.ts index aabc5a5..81f7f2d 100644 --- a/src/calls.ts +++ b/src/calls.ts @@ -106,6 +106,23 @@ async function sleep(ms: number): Promise { await new Promise((resolve) => setTimeout(resolve, ms)); } +function requirePositiveDuration(value: number, name: string): number { + if (!Number.isFinite(value) || value <= 0) { + throw new RangeError(`${name} must be a finite positive number.`); + } + return value; +} + +function waitDurations(options: WaitOptions): { + intervalMs: number; + timeoutMs: number; +} { + return { + intervalMs: requirePositiveDuration(options.intervalMs ?? 2000, "intervalMs"), + timeoutMs: requirePositiveDuration(options.timeoutMs ?? 600000, "timeoutMs") + }; +} + export class CalleCalls { private readonly client: Client; @@ -147,9 +164,15 @@ export class CalleCalls { } async get(callId: string): Promise { - const response = await this.client.GET("/v2/calls/{call_id}", { - params: { path: { call_id: callId } } - }); + return await this.getWithSignal(callId); + } + + private async getWithSignal(callId: string, signal?: AbortSignal): Promise { + const params = { path: { call_id: callId } }; + const response = + signal === undefined + ? await this.client.GET("/v2/calls/{call_id}", { params }) + : await this.client.GET("/v2/calls/{call_id}", { params, signal }); if (response.error) { throw apiErrorFromResponse(response.response.status, response.error); } @@ -192,20 +215,44 @@ export class CalleCalls { } async waitForResult(callId: string, options: WaitOptions = {}): Promise { - const intervalMs = options.intervalMs ?? 2000; - const timeoutMs = options.timeoutMs ?? 600000; + const { intervalMs, timeoutMs } = waitDurations(options); const deadline = Date.now() + timeoutMs; - while (Date.now() <= deadline) { - const call = await this.get(callId); + while (true) { + const remainingBeforeRequest = deadline - Date.now(); + if (remainingBeforeRequest <= 0) { + break; + } + + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), remainingBeforeRequest); + let call: Call; + try { + call = await this.getWithSignal(callId, controller.signal); + } catch (error) { + if (controller.signal.aborted) { + throw new CalleTimeoutError(`Timed out waiting for CALL-E call ${callId}.`); + } + throw error; + } finally { + clearTimeout(timeout); + } + if (controller.signal.aborted) { + throw new CalleTimeoutError(`Timed out waiting for CALL-E call ${callId}.`); + } if (call.resultStatus !== "pending") { return call; } - await sleep(intervalMs); + const remainingMs = deadline - Date.now(); + if (remainingMs <= 0) { + break; + } + await sleep(Math.min(intervalMs, remainingMs)); } throw new CalleTimeoutError(`Timed out waiting for CALL-E call ${callId}.`); } async createAndWait(input: CreateCallInput, options: RequestOptions & WaitOptions): Promise { + waitDurations(options); const call = await this.create(input, options); return await this.waitForResult(call.id, options); } diff --git a/tests/calls.test.ts b/tests/calls.test.ts index 69eb782..6c66882 100644 --- a/tests/calls.test.ts +++ b/tests/calls.test.ts @@ -202,4 +202,123 @@ describe("CalleClient calls", () => { CalleTimeoutError ); }); + + it("does not sleep past the requested timeout", async () => { + vi.useFakeTimers(); + try { + const queued = { + ...completedCall, + status: "queued", + call_outcome: null, + result_status: "pending", + result: null, + completed_at: null + }; + const fetchMock = vi.fn(async () => jsonResponse(queued)); + const client = new CalleClient({ apiKey: "key_test", baseUrl: "https://api.heycall-e.com", fetch: fetchMock }); + const waiting = client.calls.waitForResult("call_123", { + intervalMs: 1000, + timeoutMs: 25 + }); + const assertion = expect(waiting).rejects.toBeInstanceOf(CalleTimeoutError); + + await vi.advanceTimersByTimeAsync(25); + + await assertion; + } finally { + vi.useRealTimers(); + } + }); + + it("aborts an in-flight poll when the wait deadline expires", async () => { + vi.useFakeTimers(); + try { + const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const request = input instanceof Request ? input : new Request(input, init); + return await new Promise((_resolve, reject) => { + request.signal.addEventListener( + "abort", + () => reject(new Error("request aborted")), + { once: true } + ); + }); + }); + const client = new CalleClient({ apiKey: "key_test", baseUrl: "https://api.heycall-e.com", fetch: fetchMock }); + const waiting = client.calls.waitForResult("call_123", { + intervalMs: 1000, + timeoutMs: 25 + }); + const assertion = expect(waiting).rejects.toBeInstanceOf(CalleTimeoutError); + + await vi.advanceTimersByTimeAsync(25); + + await assertion; + expect(fetchMock).toHaveBeenCalledOnce(); + const request = fetchMock.mock.calls[0]?.[0]; + expect(request).toBeInstanceOf(Request); + expect((request as Request).signal.aborted).toBe(true); + } finally { + vi.useRealTimers(); + } + }); + + it("does not return a completed body that arrives after the deadline", async () => { + vi.useFakeTimers(); + try { + const fetchMock = vi.fn(async () => { + await new Promise((resolve) => { + setTimeout(resolve, 180); + }); + return jsonResponse(completedCall); + }); + const client = new CalleClient({ apiKey: "key_test", baseUrl: "https://api.heycall-e.com", fetch: fetchMock }); + const waiting = client.calls.waitForResult("call_123", { + intervalMs: 180, + timeoutMs: 30 + }); + const assertion = expect(waiting).rejects.toBeInstanceOf(CalleTimeoutError); + + await vi.advanceTimersByTimeAsync(180); + + await assertion; + expect(fetchMock).toHaveBeenCalledOnce(); + const request = fetchMock.mock.calls[0]?.[0]; + expect(request).toBeInstanceOf(Request); + expect((request as Request).signal.aborted).toBe(true); + } finally { + vi.useRealTimers(); + } + }); + + it.each([ + { options: { intervalMs: 0 }, name: "zero intervalMs" }, + { options: { intervalMs: Number.NaN }, name: "NaN intervalMs" }, + { options: { timeoutMs: 0 }, name: "zero timeoutMs" }, + { options: { timeoutMs: Number.POSITIVE_INFINITY }, name: "infinite timeoutMs" } + ])("rejects $name before polling", async ({ options }) => { + const fetchMock = vi.fn(async () => jsonResponse(completedCall)); + const client = new CalleClient({ apiKey: "key_test", baseUrl: "https://api.heycall-e.com", fetch: fetchMock }); + + await expect(client.calls.waitForResult("call_123", options)).rejects.toBeInstanceOf(RangeError); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("rejects invalid create-and-wait options before creating a call", async () => { + const fetchMock = vi.fn(async () => jsonResponse(completedCall)); + const client = new CalleClient({ apiKey: "key_test", baseUrl: "https://api.heycall-e.com", fetch: fetchMock }); + + await expect( + client.calls.createAndWait( + { + task: "Call.", + phone: "+14155550100", + region: "US", + locale: "en-US", + resultSchema: { type: "object", additionalProperties: false, properties: {} } + }, + { idempotencyKey: "wf_123", timeoutMs: Number.NaN } + ) + ).rejects.toBeInstanceOf(RangeError); + expect(fetchMock).not.toHaveBeenCalled(); + }); });