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
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
63 changes: 55 additions & 8 deletions src/calls.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,23 @@ async function sleep(ms: number): Promise<void> {
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<paths>;

Expand Down Expand Up @@ -147,9 +164,15 @@ export class CalleCalls {
}

async get(callId: string): Promise<Call> {
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<Call> {
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);
}
Expand Down Expand Up @@ -192,20 +215,44 @@ export class CalleCalls {
}

async waitForResult(callId: string, options: WaitOptions = {}): Promise<Call> {
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<Call> {
waitDurations(options);
const call = await this.create(input, options);
return await this.waitForResult(call.id, options);
}
Expand Down
119 changes: 119 additions & 0 deletions tests/calls.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Response>((_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<void>((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();
});
});