From dc1228c2517585d73aab39d5e94f76717f220275 Mon Sep 17 00:00:00 2001 From: Arshdeep singh Date: Mon, 14 Sep 2026 22:12:36 +0530 Subject: [PATCH] fix: attach callId and map fetch failures createAndWait discarded the created call id when the following GET failed, and a rejected fetch surfaced as TypeError instead of CalleConnectionError. Co-authored-by: Cursor --- CHANGELOG.md | 7 +++++ src/calls.ts | 49 +++++++++++++++++++++++++++---- src/errors.ts | 20 +++++++++++-- tests/calls.test.ts | 70 ++++++++++++++++++++++++++++++++++++++++++--- 4 files changed, 133 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 07b5d62..eebcf61 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- `createAndWait` attaches `callId` when the wait-phase GET fails after a + successful create. +- Fetch rejections are mapped to `CalleConnectionError` instead of a raw + `TypeError`. + ## [0.7.1] - 2026-09-03 ### Added diff --git a/src/calls.ts b/src/calls.ts index 4d353fe..d8c2f82 100644 --- a/src/calls.ts +++ b/src/calls.ts @@ -1,6 +1,11 @@ import createClient, { type Client } from "openapi-fetch"; import type { components, paths } from "./generated/schema.js"; -import { CalleConnectionError, CalleTimeoutError, apiErrorFromResponse } from "./errors.js"; +import { + CalleAPIError, + CalleConnectionError, + CalleTimeoutError, + apiErrorFromResponse +} from "./errors.js"; type ApiCall = components["schemas"]["CallTask"]; type ApiCreateCallRequest = components["schemas"]["CreateCallRequest"]; @@ -191,6 +196,29 @@ async function sleep(ms: number): Promise { await new Promise((resolve) => setTimeout(resolve, ms)); } +function wrapFetch(fetchImpl: FetchLike): FetchLike { + return (async (input: Request, init?: RequestInit) => { + try { + return await (fetchImpl as (input: Request, init?: RequestInit) => Promise)(input, init); + } catch (error) { + if ( + error instanceof CalleConnectionError || + error instanceof CalleAPIError || + error instanceof CalleTimeoutError + ) { + throw error; + } + const detail = error instanceof Error && error.message ? error.message : "unknown error"; + throw new CalleConnectionError(`CALL-E API request failed: ${detail}`); + } + }) as FetchLike; +} + +function attachCallId(error: T, callId: string): T { + error.callId = callId; + return error; +} + export class CalleCalls { private readonly client: Client; @@ -205,9 +233,7 @@ export class CalleCalls { authorization: `Bearer ${input.apiKey}` } }; - if (input.fetch !== undefined) { - clientOptions.fetch = input.fetch; - } + clientOptions.fetch = wrapFetch(input.fetch ?? ((request) => globalThis.fetch(request))); this.client = createClient(clientOptions); } @@ -276,11 +302,22 @@ export class CalleCalls { } await sleep(intervalMs); } - throw new CalleTimeoutError(`Timed out waiting for CALL-E call ${callId}.`); + throw new CalleTimeoutError(`Timed out waiting for CALL-E call ${callId}.`, callId); } async createAndWait(input: CreateCallInput, options: RequestOptions & WaitOptions = {}): Promise { const call = await this.create(input, options); - return await this.waitForResult(call.id, options); + try { + return await this.waitForResult(call.id, options); + } catch (error) { + if ( + error instanceof CalleAPIError || + error instanceof CalleConnectionError || + error instanceof CalleTimeoutError + ) { + throw attachCallId(error, call.id); + } + throw error; + } } } diff --git a/src/errors.ts b/src/errors.ts index b4cc8c2..109d47d 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -2,13 +2,17 @@ export class CalleAPIError extends Error { readonly code: string; readonly status: number; readonly details: Record; + callId?: string; - constructor(input: { code: string; message: string; status: number; details?: Record }) { + constructor(input: { code: string; message: string; status: number; details?: Record; callId?: string }) { super(input.message); this.name = "CalleAPIError"; this.code = input.code; this.status = input.status; this.details = input.details ?? {}; + if (input.callId !== undefined) { + this.callId = input.callId; + } } } @@ -27,16 +31,26 @@ export class CalleRateLimitError extends CalleAPIError { } export class CalleTimeoutError extends Error { - constructor(message: string) { + callId?: string; + + constructor(message: string, callId?: string) { super(message); this.name = "CalleTimeoutError"; + if (callId !== undefined) { + this.callId = callId; + } } } export class CalleConnectionError extends Error { - constructor(message: string) { + callId?: string; + + constructor(message: string, callId?: string) { super(message); this.name = "CalleConnectionError"; + if (callId !== undefined) { + this.callId = callId; + } } } diff --git a/tests/calls.test.ts b/tests/calls.test.ts index 5ff9e1d..884684f 100644 --- a/tests/calls.test.ts +++ b/tests/calls.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from "vitest"; -import { CalleClient, CalleAPIError, CalleTimeoutError } from "../src/index.js"; +import { CalleClient, CalleAPIError, CalleConnectionError, CalleTimeoutError } from "../src/index.js"; function jsonResponse(body: unknown, init: ResponseInit = {}) { return new Response(JSON.stringify(body), { @@ -173,8 +173,70 @@ describe("CalleClient calls", () => { const fetchMock = vi.fn(async () => jsonResponse(queued)); const client = new CalleClient({ apiKey: "key_test", baseUrl: "https://api.heycall-e.com", fetch: fetchMock }); - await expect(client.calls.waitForResult("call_123", { intervalMs: 1, timeoutMs: 2 })).rejects.toBeInstanceOf( - CalleTimeoutError - ); + await expect(client.calls.waitForResult("call_123", { intervalMs: 1, timeoutMs: 2 })).rejects.toMatchObject({ + name: "CalleTimeoutError", + callId: "call_123" + } satisfies Partial); + }); + + it("maps a rejected fetch to CalleConnectionError", async () => { + const fetchMock = vi.fn(async () => { + throw new TypeError("fetch failed"); + }); + const client = new CalleClient({ apiKey: "key_test", baseUrl: "https://api.heycall-e.com", fetch: fetchMock }); + + await expect( + client.calls.create({ + task: "Call.", + recipient: { phone: "+14155550100", region: "US", locale: "en-US" } + }) + ).rejects.toBeInstanceOf(CalleConnectionError); + }); + + it("attaches callId when createAndWait GET rejects after create", async () => { + const queued = { ...completedCall, status: "queued", structured_result: null, completed_at: null }; + const fetchMock = vi + .fn() + .mockResolvedValueOnce(jsonResponse(queued)) + .mockRejectedValueOnce(new TypeError("fetch failed")); + const client = new CalleClient({ apiKey: "key_test", baseUrl: "https://api.heycall-e.com", fetch: fetchMock }); + + await expect( + client.calls.createAndWait( + { + task: "Call.", + recipient: { phone: "+14155550100", region: "US", locale: "en-US" } + }, + { intervalMs: 1, timeoutMs: 500 } + ) + ).rejects.toMatchObject({ + name: "CalleConnectionError", + callId: "call_123" + } satisfies Partial); + }); + + it("attaches callId when createAndWait GET returns an API error", async () => { + const queued = { ...completedCall, status: "queued", structured_result: null, completed_at: null }; + const fetchMock = vi + .fn() + .mockResolvedValueOnce(jsonResponse(queued)) + .mockResolvedValueOnce( + jsonResponse({ error: { code: "internal_error", message: "Bad gateway." } }, { status: 502 }) + ); + const client = new CalleClient({ apiKey: "key_test", baseUrl: "https://api.heycall-e.com", fetch: fetchMock }); + + await expect( + client.calls.createAndWait( + { + task: "Call.", + recipient: { phone: "+14155550100", region: "US", locale: "en-US" } + }, + { intervalMs: 1, timeoutMs: 500 } + ) + ).rejects.toMatchObject({ + name: "CalleAPIError", + callId: "call_123", + status: 502 + } satisfies Partial); }); });