Skip to content

Commit 761f604

Browse files
fix(core): unwrap and parse nested gaxios streaming errors from cause message (#28689)
1 parent 63c5b74 commit 761f604

6 files changed

Lines changed: 229 additions & 7 deletions

File tree

packages/a2a-server/src/agent/task.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -131,9 +131,9 @@ export class Task {
131131
this.autoExecute = autoExecute;
132132
this.config.setFallbackModelHandler(
133133
// For a2a-server, we want to automatically switch to the fallback model
134-
// for future requests without retrying the current one. The 'stop'
135-
// intent achieves this.
136-
async () => 'stop',
134+
// and retry the current request seamlessly. The 'retry_always' intent
135+
// achieves this, ensuring a smooth fallback experience for the user.
136+
async () => 'retry_always',
137137
);
138138
}
139139

packages/core/src/utils/errorParsing.test.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,16 @@ describe('parseAndFormatApiError', () => {
109109
expect(result).toContain(vertexMessage);
110110
});
111111

112+
it('should format a StructuredError with status: undefined', () => {
113+
const error: StructuredError = {
114+
message: 'Rate limit exceeded (simulated 429 error, limit: 0)',
115+
status: undefined,
116+
};
117+
const expected =
118+
'[API Error: Rate limit exceeded (simulated 429 error, limit: 0)]';
119+
expect(parseAndFormatApiError(error)).toBe(expected);
120+
});
121+
112122
it('should handle an unknown error type', () => {
113123
const error = 12345;
114124
const expected = '[API Error: An unknown error occurred.]';

packages/core/src/utils/googleErrors.test.ts

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -445,4 +445,113 @@ describe('parseGoogleApiError', () => {
445445
expect(parsed?.code).toBe(429);
446446
expect(parsed?.message).toBe('Quota exceeded');
447447
});
448+
449+
it('should parse an error wrapped inside cause.message by gaxios', () => {
450+
const mockError = {
451+
code: 429,
452+
status: 429,
453+
cause: {
454+
message: JSON.stringify([
455+
{
456+
error: {
457+
code: 429,
458+
message:
459+
'No capacity available for model gemini-3.1-pro-preview on the server',
460+
details: [
461+
{
462+
'@type': 'type.googleapis.com/google.rpc.ErrorInfo',
463+
reason: 'MODEL_CAPACITY_EXHAUSTED',
464+
domain: 'cloudcode-pa.googleapis.com',
465+
metadata: { model: 'gemini-3.1-pro-preview' },
466+
},
467+
],
468+
},
469+
},
470+
]),
471+
code: 429,
472+
status: 'Too Many Requests',
473+
},
474+
};
475+
476+
const parsed = parseGoogleApiError(mockError);
477+
expect(parsed).not.toBeNull();
478+
expect(parsed?.code).toBe(429);
479+
expect(parsed?.message).toBe(
480+
'No capacity available for model gemini-3.1-pro-preview on the server',
481+
);
482+
expect(parsed?.details).toHaveLength(1);
483+
expect(parsed?.details[0]['@type']).toBe(
484+
'type.googleapis.com/google.rpc.ErrorInfo',
485+
);
486+
});
487+
488+
it('should parse an error where cause is a plain ErrorShape and propagate outer code', () => {
489+
const mockError = {
490+
code: 429,
491+
cause: {
492+
message: 'Quota exceeded on the server',
493+
},
494+
};
495+
496+
const parsed = parseGoogleApiError(mockError);
497+
expect(parsed).not.toBeNull();
498+
expect(parsed?.code).toBe(429);
499+
expect(parsed?.message).toBe('Quota exceeded on the server');
500+
});
501+
502+
it('should parse an error where cause is a standard Error object and propagate outer status', () => {
503+
const mockError = {
504+
status: 503,
505+
cause: new Error('Service Unavailable'),
506+
};
507+
508+
const parsed = parseGoogleApiError(mockError);
509+
expect(parsed).not.toBeNull();
510+
expect(parsed?.code).toBe(503);
511+
expect(parsed?.message).toBe('Service Unavailable');
512+
});
513+
514+
it('should defensively parse numeric string status codes from outer error', () => {
515+
const mockError = {
516+
status: '503',
517+
cause: new Error('Service Unavailable'),
518+
};
519+
520+
const parsed = parseGoogleApiError(mockError);
521+
expect(parsed).not.toBeNull();
522+
expect(parsed?.code).toBe(503);
523+
expect(parsed?.message).toBe('Service Unavailable');
524+
});
525+
526+
it('should return null for non-numeric string status codes from outer error', () => {
527+
const mockError = {
528+
status: 'Too Many Requests',
529+
cause: new Error('Quota exceeded'),
530+
};
531+
532+
const parsed = parseGoogleApiError(mockError);
533+
expect(parsed).toBeNull();
534+
});
535+
536+
it('should return null for empty or whitespace-only string status codes from outer error', () => {
537+
const mockError = {
538+
status: ' ',
539+
cause: new Error('Quota exceeded'),
540+
};
541+
542+
const parsed = parseGoogleApiError(mockError);
543+
expect(parsed).toBeNull();
544+
});
545+
546+
it('should parse an error where cause is a plain string and propagate outer status', () => {
547+
const mockError = {
548+
status: 429,
549+
cause: 'Quota exceeded on the server',
550+
};
551+
552+
const parsed = parseGoogleApiError(mockError);
553+
expect(parsed).not.toBeNull();
554+
expect(parsed?.code).toBe(429);
555+
expect(parsed?.message).toBe('Quota exceeded on the server');
556+
});
448557
});

packages/core/src/utils/googleErrors.ts

Lines changed: 82 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,18 @@ export function parseGoogleApiError(error: unknown): GoogleApiError | null {
153153
return null;
154154
}
155155

156+
// Skip parsing if the error is already a classified quota error
157+
if (
158+
typeof error === 'object' &&
159+
error !== null &&
160+
'name' in error &&
161+
(error.name === 'TerminalQuotaError' ||
162+
error.name === 'RetryableQuotaError' ||
163+
error.name === 'ValidationRequiredError')
164+
) {
165+
return null;
166+
}
167+
156168
let errorObj: unknown = error;
157169

158170
// If error is a string, try to parse it.
@@ -174,7 +186,9 @@ export function parseGoogleApiError(error: unknown): GoogleApiError | null {
174186
}
175187

176188
let currentError: ErrorShape | undefined =
177-
fromGaxiosError(errorObj) ?? fromApiError(errorObj);
189+
fromGaxiosError(errorObj) ??
190+
fromApiError(errorObj) ??
191+
fromCauseError(errorObj);
178192

179193
let depth = 0;
180194
const maxDepth = 10;
@@ -371,3 +385,70 @@ function fromApiError(errorObj: object): ErrorShape | undefined {
371385
}
372386
return outerError;
373387
}
388+
389+
function fromCauseError(errorObj: object): ErrorShape | undefined {
390+
const err = errorObj as {
391+
code?: unknown;
392+
status?: unknown;
393+
cause?: unknown;
394+
};
395+
if (!err.cause) return undefined;
396+
397+
const rawCode = err.code ?? err.status;
398+
const fallbackCode =
399+
typeof rawCode === 'number'
400+
? rawCode
401+
: typeof rawCode === 'string' &&
402+
rawCode.trim() !== '' &&
403+
!isNaN(Number(rawCode))
404+
? Number(rawCode)
405+
: undefined;
406+
407+
const resolveError = (
408+
resolved: ErrorShape | undefined,
409+
): ErrorShape | undefined => {
410+
if (!resolved) return undefined;
411+
const message = resolved.message;
412+
const details = resolved.details;
413+
const code = resolved.code ?? fallbackCode;
414+
return {
415+
...(message !== undefined ? { message } : {}),
416+
...(details !== undefined ? { details } : {}),
417+
...(code !== undefined ? { code } : {}),
418+
};
419+
};
420+
421+
if (typeof err.cause === 'object' && err.cause !== null) {
422+
if (
423+
'error' in err.cause &&
424+
err.cause.error &&
425+
isErrorShape(err.cause.error)
426+
) {
427+
return resolveError(err.cause.error);
428+
}
429+
if ('message' in err.cause && err.cause.message) {
430+
if (typeof err.cause.message === 'string') {
431+
const parsed = fromApiError({ message: err.cause.message });
432+
if (parsed) return resolveError(parsed);
433+
} else if (
434+
typeof err.cause.message === 'object' &&
435+
err.cause.message !== null
436+
) {
437+
const msgObj = err.cause.message as { error?: unknown };
438+
if (msgObj.error && isErrorShape(msgObj.error)) {
439+
return resolveError(msgObj.error);
440+
}
441+
}
442+
}
443+
if (isErrorShape(err.cause)) {
444+
return resolveError(err.cause);
445+
}
446+
}
447+
if (typeof err.cause === 'string' && err.cause.trim() !== '') {
448+
const parsed = fromApiError({ message: err.cause }) ?? {
449+
message: err.cause,
450+
};
451+
return resolveError(parsed);
452+
}
453+
return undefined;
454+
}

packages/core/src/utils/googleQuotaErrors.ts

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,15 +28,17 @@ enum GoogleApiType {
2828
export class TerminalQuotaError extends Error {
2929
retryDelayMs?: number;
3030
reason?: string;
31+
status?: number;
3132

3233
constructor(
3334
message: string,
34-
override readonly cause: GoogleApiError,
35+
override readonly cause?: GoogleApiError,
3536
retryDelaySeconds?: number,
3637
reason?: string,
3738
) {
3839
super(message);
3940
this.name = 'TerminalQuotaError';
41+
this.status = cause?.code;
4042
this.retryDelayMs = retryDelaySeconds
4143
? retryDelaySeconds * 1000
4244
: undefined;
@@ -53,14 +55,16 @@ export class TerminalQuotaError extends Error {
5355
*/
5456
export class RetryableQuotaError extends Error {
5557
retryDelayMs?: number;
58+
status?: number;
5659

5760
constructor(
5861
message: string,
59-
override readonly cause: GoogleApiError,
62+
override readonly cause?: GoogleApiError,
6063
retryDelaySeconds?: number,
6164
) {
6265
super(message);
6366
this.name = 'RetryableQuotaError';
67+
this.status = cause?.code;
6468
this.retryDelayMs = retryDelaySeconds
6569
? retryDelaySeconds * 1000
6670
: undefined;
@@ -217,6 +221,20 @@ function classifyValidationRequiredError(
217221
* @returns A classified error or the original `unknown` error.
218222
*/
219223
export function classifyGoogleError(error: unknown): unknown {
224+
if (
225+
error instanceof TerminalQuotaError ||
226+
error instanceof RetryableQuotaError ||
227+
error instanceof ValidationRequiredError ||
228+
(typeof error === 'object' &&
229+
error !== null &&
230+
'name' in error &&
231+
(error.name === 'TerminalQuotaError' ||
232+
error.name === 'RetryableQuotaError' ||
233+
error.name === 'ValidationRequiredError'))
234+
) {
235+
return error;
236+
}
237+
220238
const googleApiError = parseGoogleApiError(error);
221239
const status = googleApiError?.code ?? getErrorStatus(error);
222240
const errorMessage = googleApiError?.message || extractErrorMessage(error);

packages/core/src/utils/quotaErrorDetection.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,11 @@ export function isStructuredError(error: unknown): error is StructuredError {
4141
if (typeof error.message !== 'string') {
4242
return false;
4343
}
44-
if ('status' in error && typeof error.status !== 'number') {
44+
if (
45+
'status' in error &&
46+
error.status !== undefined &&
47+
typeof error.status !== 'number'
48+
) {
4549
return false;
4650
}
4751
return true;

0 commit comments

Comments
 (0)