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
21 changes: 16 additions & 5 deletions src/services/base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { conditions } from "#conditions";
import { DEFAULT_FETCH_INIT } from "#fetch";
import { BaseServiceParams, Error as JsonError } from "..";
import { buildUserAgent } from "../utils/userAgent";
import { AssemblyAIError } from "../utils/errors";

/**
* Base class for services that communicate with the API.
Expand Down Expand Up @@ -60,18 +61,28 @@ export abstract class BaseService {
const response = await this.fetchResponse(input, init);

if (response.status >= 400) {
let json: JsonError | undefined;
let message: string | undefined;
const text = await response.text();
if (text) {
try {
json = JSON.parse(text);
const json: JsonError = JSON.parse(text);
if (json?.error) message = json.error;
} catch {
/* empty */
}
if (json?.error) throw new Error(json.error);
throw new Error(text);
if (!message) message = text;
}
throw new Error(`HTTP Error: ${response.status} ${response.statusText}`);
if (!message) {
message = `HTTP Error: ${response.status} ${response.statusText}`;
}

const retryHeader = response.headers.get("retry-after");
const retryAfter =
retryHeader && /^\d+$/.test(retryHeader)
? parseInt(retryHeader, 10)
: undefined;

throw new AssemblyAIError(message, response.status, retryAfter);
}

return response;
Expand Down
2 changes: 2 additions & 0 deletions src/services/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
} from "./tts";
import { TextToSpeechError } from "../utils/errors/tts";
import {
AssemblyAIError,
SyncTranscriptError,
DictationError,
LlmGatewayError,
Expand Down Expand Up @@ -141,6 +142,7 @@ class AssemblyAI {

export {
AssemblyAI,
AssemblyAIError,
SyncTranscriber,
SyncLiveSession,
SyncTranscriptError,
Expand Down
21 changes: 21 additions & 0 deletions src/utils/errors/assemblyai.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
/**
* Error thrown when an AssemblyAI API request fails.
*/
export class AssemblyAIError extends Error {
override name = "AssemblyAIError";

/**
* Create a new AssemblyAIError.
* @param message - The human-readable error message.
* @param status - The HTTP status code of the failed request.
* @param retryAfter - Seconds to wait before retrying, from the
* `Retry-After` header on 429/503 responses if present.
*/
constructor(
message: string,
public readonly status?: number,
public readonly retryAfter?: number,
) {
super(message);
}
}
10 changes: 6 additions & 4 deletions src/utils/errors/dictation.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import { AssemblyAIError } from "./assemblyai";

/**
* Error thrown when a dictation request fails.
*/
export class DictationError extends Error {
export class DictationError extends AssemblyAIError {
override name = "DictationError";

/**
Expand All @@ -16,10 +18,10 @@ export class DictationError extends Error {
*/
constructor(
message: string,
public readonly status?: number,
status?: number,
public readonly errorCode?: string,
public readonly retryAfter?: number,
retryAfter?: number,
) {
super(message);
super(message, status, retryAfter);
}
}
1 change: 1 addition & 0 deletions src/utils/errors/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export { AssemblyAIError } from "./assemblyai";
export { SyncTranscriptError } from "./sync";
export { DictationError } from "./dictation";
export { LlmGatewayError } from "./llm-gateway";
Expand Down
8 changes: 5 additions & 3 deletions src/utils/errors/llm-gateway.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import { AssemblyAIError } from "./assemblyai";

/**
* Error thrown when a request to the LLM Gateway fails.
*/
export class LlmGatewayError extends Error {
export class LlmGatewayError extends AssemblyAIError {
override name = "LlmGatewayError";

/**
Expand All @@ -13,10 +15,10 @@ export class LlmGatewayError extends Error {
*/
constructor(
message: string,
public readonly status?: number,
status?: number,
public readonly requestId?: string,
public readonly errors?: string[],
) {
super(message);
super(message, status);
}
}
10 changes: 6 additions & 4 deletions src/utils/errors/sync.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import { AssemblyAIError } from "./assemblyai";

/**
* Error thrown when a synchronous transcription request fails.
*/
export class SyncTranscriptError extends Error {
export class SyncTranscriptError extends AssemblyAIError {
override name = "SyncTranscriptError";

/**
Expand All @@ -16,10 +18,10 @@ export class SyncTranscriptError extends Error {
*/
constructor(
message: string,
public readonly status?: number,
status?: number,
public readonly errorCode?: string,
public readonly retryAfter?: number,
retryAfter?: number,
) {
super(message);
super(message, status, retryAfter);
}
}
8 changes: 4 additions & 4 deletions src/utils/errors/tts.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { AssemblyAIError } from "./assemblyai";

/** The fields a Text-to-Speech failure carries besides its message. */
export type TextToSpeechErrorFields = {
/**
Expand Down Expand Up @@ -59,22 +61,20 @@ export const RETRYABLE_STATUSES: ReadonlySet<number> = new Set([
* Branch on `errorCode`, not on the message, which may be reworded.
*/
export class TextToSpeechError
extends Error
extends AssemblyAIError
implements TextToSpeechErrorFields
{
override name = "TextToSpeechError";
status?: number;
errorCode?: string;
param?: string;
requestId?: string;
retryable?: boolean;
retryAfter?: number;
errors?: { message: string; param: string | null }[];
closeCode?: number;
body?: Record<string, unknown>;

constructor(message: string, fields: TextToSpeechErrorFields = {}) {
super(message);
super(message, fields.status, fields.retryAfter);
Object.assign(this, fields);
}
}
171 changes: 171 additions & 0 deletions tests/unit/assemblyai-error.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
import fetchMock from "jest-fetch-mock";
import {
AssemblyAIError,
SyncTranscriptError,
DictationError,
LlmGatewayError,
TextToSpeechError,
} from "../../src";
import { createClient } from "./utils";

fetchMock.enableMocks();

describe("AssemblyAIError and BaseService error handling", () => {
const client = createClient();

beforeEach(() => {
jest.clearAllMocks();
fetchMock.resetMocks();
fetchMock.doMock();
});

it("throws AssemblyAIError on JSON error body with status and error message", async () => {
fetchMock.mockResponseOnce(
JSON.stringify({ error: "audio_url is required" }),
{ status: 400, statusText: "Bad Request" },
);

let caught: unknown;
try {
await client.transcripts.submit({
audio: "https://example.com/audio.mp3",
});
} catch (err) {
caught = err;
}

expect(caught).toBeInstanceOf(Error);
expect(caught).toBeInstanceOf(AssemblyAIError);
const error = caught as AssemblyAIError;
expect(error.name).toBe("AssemblyAIError");
expect(error.message).toBe("audio_url is required");
expect(error.status).toBe(400);
expect(error.retryAfter).toBeUndefined();
});

it("throws AssemblyAIError on plain text error body", async () => {
fetchMock.mockResponseOnce("Service Unavailable", {
status: 503,
statusText: "Service Unavailable",
});

let caught: unknown;
try {
await client.transcripts.get("tx-123");
} catch (err) {
caught = err;
}

expect(caught).toBeInstanceOf(AssemblyAIError);
const error = caught as AssemblyAIError;
expect(error.message).toBe("Service Unavailable");
expect(error.status).toBe(503);
expect(error.retryAfter).toBeUndefined();
});

it("throws AssemblyAIError on empty error body with status and statusText", async () => {
fetchMock.mockResponseOnce("", {
status: 404,
statusText: "Not Found",
});

let caught: unknown;
try {
await client.transcripts.get("tx-nonexistent");
} catch (err) {
caught = err;
}

expect(caught).toBeInstanceOf(AssemblyAIError);
const error = caught as AssemblyAIError;
expect(error.message).toBe("HTTP Error: 404 Not Found");
expect(error.status).toBe(404);
});

it("extracts numeric Retry-After header on 429 rate limit responses", async () => {
fetchMock.mockResponseOnce(
JSON.stringify({ error: "Rate limit exceeded" }),
{
status: 429,
statusText: "Too Many Requests",
headers: { "Retry-After": "25" },
},
);

let caught: unknown;
try {
await client.transcripts.submit({
audio: "https://example.com/audio.mp3",
});
} catch (err) {
caught = err;
}

expect(caught).toBeInstanceOf(AssemblyAIError);
const error = caught as AssemblyAIError;
expect(error.message).toBe("Rate limit exceeded");
expect(error.status).toBe(429);
expect(error.retryAfter).toBe(25);
});

it("handles non-numeric or absent Retry-After header gracefully", async () => {
fetchMock.mockResponseOnce(JSON.stringify({ error: "Too many requests" }), {
status: 429,
statusText: "Too Many Requests",
headers: { "Retry-After": "invalid-value" },
});

let caught: unknown;
try {
await client.transcripts.submit({
audio: "https://example.com/audio.mp3",
});
} catch (err) {
caught = err;
}

expect(caught).toBeInstanceOf(AssemblyAIError);
const error = caught as AssemblyAIError;
expect(error.status).toBe(429);
expect(error.retryAfter).toBeUndefined();
});

it("subclasses inherit from AssemblyAIError for unified error catching", () => {
const syncErr = new SyncTranscriptError("sync error", 400, "bad_audio", 5);
expect(syncErr).toBeInstanceOf(AssemblyAIError);
expect(syncErr).toBeInstanceOf(SyncTranscriptError);
expect(syncErr.status).toBe(400);
expect(syncErr.errorCode).toBe("bad_audio");
expect(syncErr.retryAfter).toBe(5);

const dictErr = new DictationError(
"dictation error",
429,
"rate_limited",
10,
);
expect(dictErr).toBeInstanceOf(AssemblyAIError);
expect(dictErr).toBeInstanceOf(DictationError);
expect(dictErr.status).toBe(429);
expect(dictErr.errorCode).toBe("rate_limited");
expect(dictErr.retryAfter).toBe(10);

const llmErr = new LlmGatewayError("llm error", 500, "req-1", ["err1"]);
expect(llmErr).toBeInstanceOf(AssemblyAIError);
expect(llmErr).toBeInstanceOf(LlmGatewayError);
expect(llmErr.status).toBe(500);
expect(llmErr.requestId).toBe("req-1");
expect(llmErr.errors).toEqual(["err1"]);

const ttsErr = new TextToSpeechError("tts error", {
status: 503,
retryAfter: 3,
errorCode: "auth_unavailable",
});
expect(ttsErr).toBeInstanceOf(AssemblyAIError);
expect(ttsErr).toBeInstanceOf(TextToSpeechError);
expect(ttsErr.status).toBe(503);
expect(ttsErr.retryAfter).toBe(3);
expect(ttsErr.errorCode).toBe("auth_unavailable");
});
});
24 changes: 23 additions & 1 deletion tests/unit/exports.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,11 @@ import type {
Transcript,
TranscriptParams,
} from "../../src/exports";
import { AssemblyAI, TextToSpeechError } from "../../src/exports";
import {
AssemblyAI,
AssemblyAIError,
TextToSpeechError,
} from "../../src/exports";

// #105: every type a public field is declared with must be reachable from the
// package root. `LiteralUnion` sits behind `language_code` and
Expand Down Expand Up @@ -48,6 +52,24 @@ describe("package root exports", () => {
expect(carried.retryAfter).toBe(2);
});

it("exposes AssemblyAIError and inherits across specific errors", () => {
const error = new AssemblyAIError("Something went wrong", 400, 5);
expect(error).toBeInstanceOf(Error);
expect(error).toBeInstanceOf(AssemblyAIError);
expect(error.name).toBe("AssemblyAIError");
expect(error.message).toBe("Something went wrong");
expect(error.status).toBe(400);
expect(error.retryAfter).toBe(5);

const ttsError = new TextToSpeechError("TTS failed", {
status: 503,
retryAfter: 3,
});
expect(ttsError).toBeInstanceOf(AssemblyAIError);
expect(ttsError.status).toBe(503);
expect(ttsError.retryAfter).toBe(3);
});

it("exposes the HTTP text-to-speech types under the product's prefix", () => {
// The root also exports speech-to-text types such as `SpeechModel` and
// `SpeechUnderstandingRequest`, so the types `synthesize()` and
Expand Down