feat: attempt more error parsing (#14899)

This commit is contained in:
Adam Weidman
2025-12-10 11:14:07 -08:00
committed by GitHub
parent c8b688655c
commit 22e6af414a
4 changed files with 100 additions and 25 deletions

View File

@@ -94,7 +94,12 @@ describe('parseGoogleApiError', () => {
},
},
};
expect(parseGoogleApiError(mockError)).toBeNull();
expect(parseGoogleApiError(mockError)).toEqual({
code: 400,
message: 'Bad Request',
details: [],
});
});
it('should return null if there are no valid details', () => {
@@ -115,7 +120,11 @@ describe('parseGoogleApiError', () => {
},
},
};
expect(parseGoogleApiError(mockError)).toBeNull();
expect(parseGoogleApiError(mockError)).toEqual({
code: 400,
message: 'Bad Request',
details: [],
});
});
it('should parse a doubly nested error in the message', () => {

View File

@@ -190,32 +190,32 @@ export function parseGoogleApiError(error: unknown): GoogleApiError | null {
const message = currentError.message;
const errorDetails = currentError.details;
if (Array.isArray(errorDetails) && code && message) {
if (code && message) {
const details: GoogleApiErrorDetail[] = [];
for (const detail of errorDetails) {
if (detail && typeof detail === 'object') {
const detailObj = detail as Record<string, unknown>;
const typeKey = Object.keys(detailObj).find(
(key) => key.trim() === '@type',
);
if (typeKey) {
if (typeKey !== '@type') {
detailObj['@type'] = detailObj[typeKey];
delete detailObj[typeKey];
if (Array.isArray(errorDetails)) {
for (const detail of errorDetails) {
if (detail && typeof detail === 'object') {
const detailObj = detail as Record<string, unknown>;
const typeKey = Object.keys(detailObj).find(
(key) => key.trim() === '@type',
);
if (typeKey) {
if (typeKey !== '@type') {
detailObj['@type'] = detailObj[typeKey];
delete detailObj[typeKey];
}
// We can just cast it; the consumer will have to switch on @type
details.push(detailObj as unknown as GoogleApiErrorDetail);
}
// We can just cast it; the consumer will have to switch on @type
details.push(detailObj as unknown as GoogleApiErrorDetail);
}
}
}
if (details.length > 0) {
return {
code,
message,
details,
};
}
return {
code,
message,
details,
};
}
return null;
@@ -288,6 +288,18 @@ function fromApiError(errorObj: object): ErrorShape | undefined {
data = JSON.parse(data);
} catch (_) {
// Not a JSON string, can't parse.
// Try one more fallback: look for the first '{' and last '}'
if (typeof data === 'string') {
const firstBrace = data.indexOf('{');
const lastBrace = data.lastIndexOf('}');
if (firstBrace !== -1 && lastBrace !== -1 && lastBrace > firstBrace) {
try {
data = JSON.parse(data.substring(firstBrace, lastBrace + 1));
} catch (__) {
// Still failed
}
}
}
}
}

View File

@@ -12,6 +12,7 @@ import {
} from './googleQuotaErrors.js';
import * as errorParser from './googleErrors.js';
import type { GoogleApiError } from './googleErrors.js';
import { ModelNotFoundError } from './httpErrors.js';
describe('classifyGoogleError', () => {
afterEach(() => {
@@ -341,4 +342,51 @@ describe('classifyGoogleError', () => {
const result = classifyGoogleError(originalError);
expect(result).toBe(originalError);
});
it('should classify nested JSON string 404 error as ModelNotFoundError', () => {
// Mimic the double-wrapped JSON structure seen in the user report
const innerError = {
error: {
code: 404,
message:
'models/NOT_FOUND is not found for API version v1beta, or is not supported for generateContent. Call ListModels to see the list of available models and their supported methods.',
status: 'NOT_FOUND',
},
};
const errorString = JSON.stringify(innerError);
const outerErrorString = JSON.stringify({
error: {
message: errorString,
},
});
const error = new Error(`[API Error: ${outerErrorString}]`);
const classified = classifyGoogleError(error);
expect(classified).toBeInstanceOf(ModelNotFoundError);
expect((classified as ModelNotFoundError).code).toBe(404);
});
it('should fallback to string parsing for retry delays when details array is empty', () => {
const errorWithEmptyDetails = {
error: {
code: 429,
message: 'Resource exhausted. Please retry in 5s',
details: [],
},
};
const result = classifyGoogleError(errorWithEmptyDetails);
expect(result).toBeInstanceOf(RetryableQuotaError);
if (result instanceof RetryableQuotaError) {
expect(result.retryDelayMs).toBe(5000);
// The cause should be the parsed GoogleApiError
expect(result.cause).toEqual({
code: 429,
message: 'Resource exhausted. Please retry in 5s',
details: [],
});
}
});
});

View File

@@ -89,9 +89,15 @@ export function classifyGoogleError(error: unknown): unknown {
return new ModelNotFoundError(message, status);
}
if (!googleApiError || googleApiError.code !== 429) {
if (
!googleApiError ||
googleApiError.code !== 429 ||
googleApiError.details.length === 0
) {
// Fallback: try to parse the error message for a retry delay
const errorMessage = error instanceof Error ? error.message : String(error);
const errorMessage =
googleApiError?.message ||
(error instanceof Error ? error.message : String(error));
const match = errorMessage.match(/Please retry in ([0-9.]+(?:ms|s))/);
if (match?.[1]) {
const retryDelaySeconds = parseDurationInSeconds(match[1]);
@@ -108,7 +114,7 @@ export function classifyGoogleError(error: unknown): unknown {
}
}
return error; // Not a 429 error we can handle.
return error; // Not a 429 error we can handle with structured details or a parsable retry message.
}
const quotaFailure = googleApiError.details.find(