From 4846a46b9312884cddbe637a94d2cdea126cad15 Mon Sep 17 00:00:00 2001 From: pylrn Date: Sat, 31 Oct 2026 21:13:27 +0530 Subject: [PATCH] feat(errors): introduce AssemblyAIError with HTTP status and retryAfter (#80) - Add AssemblyAIError extending Error with status?: number and retryAfter?: number - Update BaseService.fetch to throw AssemblyAIError instead of generic Error, preserving exact error messages from JSON error bodies, plain text, or HTTP status fallback while attaching HTTP status code and Retry-After header value when present - Make SyncTranscriptError, DictationError, LlmGatewayError, and TextToSpeechError inherit from AssemblyAIError to unify error handling across all SDK services - Export AssemblyAIError from package root - Add unit tests covering JSON error, plain text error, empty body, and Retry-After header extraction, along with inheritance checks --- src/services/base.ts | 21 +++- src/services/index.ts | 2 + src/utils/errors/assemblyai.ts | 21 ++++ src/utils/errors/dictation.ts | 10 +- src/utils/errors/index.ts | 1 + src/utils/errors/llm-gateway.ts | 8 +- src/utils/errors/sync.ts | 10 +- src/utils/errors/tts.ts | 8 +- tests/unit/assemblyai-error.test.ts | 171 ++++++++++++++++++++++++++++ tests/unit/exports.test.ts | 24 +++- 10 files changed, 255 insertions(+), 21 deletions(-) create mode 100644 src/utils/errors/assemblyai.ts create mode 100644 tests/unit/assemblyai-error.test.ts diff --git a/src/services/base.ts b/src/services/base.ts index 897d322..fac5dbb 100644 --- a/src/services/base.ts +++ b/src/services/base.ts @@ -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. @@ -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; diff --git a/src/services/index.ts b/src/services/index.ts index b69813b..68dbd0d 100644 --- a/src/services/index.ts +++ b/src/services/index.ts @@ -9,6 +9,7 @@ import { } from "./tts"; import { TextToSpeechError } from "../utils/errors/tts"; import { + AssemblyAIError, SyncTranscriptError, DictationError, LlmGatewayError, @@ -141,6 +142,7 @@ class AssemblyAI { export { AssemblyAI, + AssemblyAIError, SyncTranscriber, SyncLiveSession, SyncTranscriptError, diff --git a/src/utils/errors/assemblyai.ts b/src/utils/errors/assemblyai.ts new file mode 100644 index 0000000..ef5bb7f --- /dev/null +++ b/src/utils/errors/assemblyai.ts @@ -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); + } +} diff --git a/src/utils/errors/dictation.ts b/src/utils/errors/dictation.ts index 5d0de17..7f04c17 100644 --- a/src/utils/errors/dictation.ts +++ b/src/utils/errors/dictation.ts @@ -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"; /** @@ -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); } } diff --git a/src/utils/errors/index.ts b/src/utils/errors/index.ts index 757de3b..2d8e862 100644 --- a/src/utils/errors/index.ts +++ b/src/utils/errors/index.ts @@ -1,3 +1,4 @@ +export { AssemblyAIError } from "./assemblyai"; export { SyncTranscriptError } from "./sync"; export { DictationError } from "./dictation"; export { LlmGatewayError } from "./llm-gateway"; diff --git a/src/utils/errors/llm-gateway.ts b/src/utils/errors/llm-gateway.ts index 1718371..249405e 100644 --- a/src/utils/errors/llm-gateway.ts +++ b/src/utils/errors/llm-gateway.ts @@ -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"; /** @@ -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); } } diff --git a/src/utils/errors/sync.ts b/src/utils/errors/sync.ts index 52a3180..0a6fe29 100644 --- a/src/utils/errors/sync.ts +++ b/src/utils/errors/sync.ts @@ -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"; /** @@ -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); } } diff --git a/src/utils/errors/tts.ts b/src/utils/errors/tts.ts index 0b712d3..3af750e 100644 --- a/src/utils/errors/tts.ts +++ b/src/utils/errors/tts.ts @@ -1,3 +1,5 @@ +import { AssemblyAIError } from "./assemblyai"; + /** The fields a Text-to-Speech failure carries besides its message. */ export type TextToSpeechErrorFields = { /** @@ -59,22 +61,20 @@ export const RETRYABLE_STATUSES: ReadonlySet = 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; constructor(message: string, fields: TextToSpeechErrorFields = {}) { - super(message); + super(message, fields.status, fields.retryAfter); Object.assign(this, fields); } } diff --git a/tests/unit/assemblyai-error.test.ts b/tests/unit/assemblyai-error.test.ts new file mode 100644 index 0000000..d2cda1c --- /dev/null +++ b/tests/unit/assemblyai-error.test.ts @@ -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"); + }); +}); diff --git a/tests/unit/exports.test.ts b/tests/unit/exports.test.ts index 973833a..8a3f440 100644 --- a/tests/unit/exports.test.ts +++ b/tests/unit/exports.test.ts @@ -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 @@ -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