Skip to content

Commit 01968dc

Browse files
committed
feat(voice): VoicePipelineError + AggregateVoiceError with structured classification
Adds a Error subclass carrying { kind, provider, errorClass, retryable, cause } so chains and circuit breakers can react to failures without parsing error.message. classifyError() maps common HTTP + Node error shapes (401/403/auth, 429/rate-limit, 5xx/service, ECONNRESET/network) into the HealthErrorClass union. Foundation for the voice pipeline resilience plan (Task 1/17).
1 parent 69ced36 commit 01968dc

2 files changed

Lines changed: 201 additions & 0 deletions

File tree

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
/**
2+
* @module voice-pipeline/VoicePipelineError
3+
*
4+
* Structured error class for voice pipeline failures. Carries enough shape
5+
* for chains and circuit breakers to classify and react without stringly
6+
* matching error.message.
7+
*/
8+
9+
export type HealthErrorClass =
10+
| 'auth'
11+
| 'quota'
12+
| 'network'
13+
| 'service'
14+
| 'unknown';
15+
16+
export interface VoicePipelineErrorInit {
17+
kind: 'stt' | 'tts' | 'transport';
18+
provider: string;
19+
errorClass: HealthErrorClass;
20+
message: string;
21+
cause?: unknown;
22+
retryable: boolean;
23+
}
24+
25+
export class VoicePipelineError extends Error {
26+
readonly kind: VoicePipelineErrorInit['kind'];
27+
readonly provider: string;
28+
readonly errorClass: HealthErrorClass;
29+
readonly retryable: boolean;
30+
readonly cause?: unknown;
31+
32+
constructor(init: VoicePipelineErrorInit) {
33+
super(init.message);
34+
this.name = 'VoicePipelineError';
35+
this.kind = init.kind;
36+
this.provider = init.provider;
37+
this.errorClass = init.errorClass;
38+
this.retryable = init.retryable;
39+
this.cause = init.cause;
40+
}
41+
42+
/**
43+
* Best-effort classification of an arbitrary error into a voice-pipeline
44+
* error with a well-known errorClass. Preserves the original error as
45+
* `cause` so upstream inspection can still recover provider-specific
46+
* detail.
47+
*/
48+
static classifyError(
49+
err: unknown,
50+
meta: { kind: VoicePipelineErrorInit['kind']; provider: string }
51+
): VoicePipelineError {
52+
const raw = err instanceof Error ? err : new Error(String(err));
53+
const msg = raw.message ?? '';
54+
const code = (err as { code?: string } | null)?.code ?? '';
55+
56+
let errorClass: HealthErrorClass = 'unknown';
57+
let retryable = true;
58+
59+
if (/\b401\b|unauthori[sz]ed|invalid api key|forbidden|\b403\b/i.test(msg)) {
60+
errorClass = 'auth';
61+
retryable = false;
62+
} else if (/\b429\b|rate.?limit|too many/i.test(msg)) {
63+
errorClass = 'quota';
64+
retryable = true;
65+
} else if (/\b5\d\d\b|internal server|bad gateway|gateway timeout|service unavailable/i.test(msg)) {
66+
errorClass = 'service';
67+
retryable = true;
68+
} else if (
69+
code === 'ECONNRESET' ||
70+
code === 'ETIMEDOUT' ||
71+
code === 'ENOTFOUND' ||
72+
/econnreset|etimedout|enotfound|socket hang up|network/i.test(msg)
73+
) {
74+
errorClass = 'network';
75+
retryable = true;
76+
}
77+
78+
return new VoicePipelineError({
79+
kind: meta.kind,
80+
provider: meta.provider,
81+
errorClass,
82+
message: msg || 'unknown voice pipeline error',
83+
cause: err,
84+
retryable,
85+
});
86+
}
87+
}
88+
89+
/**
90+
* Aggregate thrown by `StreamingSTTChain` / `StreamingTTSChain` when every
91+
* candidate provider fails. Carries the per-provider error list so callers
92+
* can display a breakdown rather than a single confusing message.
93+
*/
94+
export class AggregateVoiceError extends Error {
95+
readonly attempts: VoicePipelineError[];
96+
97+
constructor(attempts: VoicePipelineError[]) {
98+
const summary = attempts
99+
.map((a) => `${a.provider}: ${a.errorClass} \u2014 ${a.message}`)
100+
.join('; ');
101+
super(`All ${attempts.length} providers failed \u2014 ${summary}`);
102+
this.name = 'AggregateVoiceError';
103+
this.attempts = attempts;
104+
}
105+
}
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import { describe, it, expect } from 'vitest';
2+
import {
3+
VoicePipelineError,
4+
AggregateVoiceError,
5+
type HealthErrorClass,
6+
} from '../VoicePipelineError.js';
7+
8+
describe('VoicePipelineError', () => {
9+
it('carries structured fields and a cause', () => {
10+
const cause = new Error('upstream');
11+
const err = new VoicePipelineError({
12+
kind: 'stt',
13+
provider: 'deepgram',
14+
errorClass: 'auth',
15+
message: 'invalid api key',
16+
cause,
17+
retryable: false,
18+
});
19+
20+
expect(err.kind).toBe('stt');
21+
expect(err.provider).toBe('deepgram');
22+
expect(err.errorClass).toBe('auth');
23+
expect(err.retryable).toBe(false);
24+
expect(err.cause).toBe(cause);
25+
expect(err.message).toBe('invalid api key');
26+
expect(err).toBeInstanceOf(Error);
27+
expect(err.name).toBe('VoicePipelineError');
28+
});
29+
30+
it('classifyError maps common error shapes', () => {
31+
const auth = VoicePipelineError.classifyError(
32+
new Error('401 Unauthorized'),
33+
{ kind: 'stt', provider: 'deepgram' }
34+
);
35+
expect(auth.errorClass).toBe('auth');
36+
expect(auth.retryable).toBe(false);
37+
38+
const quota = VoicePipelineError.classifyError(
39+
new Error('429 Too Many Requests'),
40+
{ kind: 'tts', provider: 'elevenlabs' }
41+
);
42+
expect(quota.errorClass).toBe('quota');
43+
expect(quota.retryable).toBe(true);
44+
45+
const network = VoicePipelineError.classifyError(
46+
Object.assign(new Error('ECONNRESET'), { code: 'ECONNRESET' }),
47+
{ kind: 'stt', provider: 'deepgram' }
48+
);
49+
expect(network.errorClass).toBe('network');
50+
expect(network.retryable).toBe(true);
51+
52+
const service = VoicePipelineError.classifyError(
53+
new Error('500 Internal Server Error'),
54+
{ kind: 'tts', provider: 'openai-realtime' }
55+
);
56+
expect(service.errorClass).toBe('service');
57+
expect(service.retryable).toBe(true);
58+
59+
const unknown = VoicePipelineError.classifyError(
60+
new Error('something weird'),
61+
{ kind: 'stt', provider: 'deepgram' }
62+
);
63+
expect(unknown.errorClass).toBe('unknown');
64+
});
65+
});
66+
67+
describe('AggregateVoiceError', () => {
68+
it('summarizes per-provider attempts', () => {
69+
const attempts: VoicePipelineError[] = [
70+
new VoicePipelineError({
71+
kind: 'stt',
72+
provider: 'deepgram',
73+
errorClass: 'auth',
74+
message: 'invalid key',
75+
retryable: false,
76+
}),
77+
new VoicePipelineError({
78+
kind: 'stt',
79+
provider: 'elevenlabs',
80+
errorClass: 'network',
81+
message: 'ECONNRESET',
82+
retryable: true,
83+
}),
84+
];
85+
const agg = new AggregateVoiceError(attempts);
86+
expect(agg.attempts).toHaveLength(2);
87+
expect(agg.name).toBe('AggregateVoiceError');
88+
expect(agg.message).toContain('deepgram');
89+
expect(agg.message).toContain('elevenlabs');
90+
expect(agg.message).toContain('auth');
91+
});
92+
});
93+
94+
// Compile-time check that HealthErrorClass is exported and usable.
95+
const _typeCheck: HealthErrorClass = 'auth';
96+
void _typeCheck;

0 commit comments

Comments
 (0)