mirror of
https://github.com/andy-stack/vaultkeeper-ai.git
synced 2026-07-22 06:42:03 +00:00
Add provider-specific retry delay extraction for rate limits
Implement extractRetryDelay methods in Claude, Gemini, and OpenAI classes to parse provider-specific retry delay headers/responses. Update StreamingService to use these delays when available, falling back to exponential backoff. Enhance ApiError to include response headers and body for retry delay extraction.
This commit is contained in:
parent
fb20108dc9
commit
2c7e5b41b6
5 changed files with 120 additions and 10 deletions
|
|
@ -13,6 +13,7 @@ import { Exception } from "Helpers/Exception";
|
|||
import { MimeType, toMimeType } from "Enums/MimeType";
|
||||
import { isTextFile } from "Enums/FileType";
|
||||
import { MimeTypeToFileTypes } from "Enums/FileTypeMimeTypeMapping";
|
||||
import { ApiError, ApiErrorType } from "Types/ApiError";
|
||||
|
||||
export class Claude extends BaseAIClass {
|
||||
|
||||
|
|
@ -89,7 +90,8 @@ export class Claude extends BaseAIClass {
|
|||
AIProviderURL.Claude,
|
||||
requestBody,
|
||||
(chunk: string) => this.parseStreamChunk(chunk),
|
||||
headers
|
||||
headers,
|
||||
(error) => this.extractRetryDelay(error)
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -309,6 +311,29 @@ export class Claude extends BaseAIClass {
|
|||
return JSON.stringify(contentBlocks);
|
||||
}
|
||||
|
||||
private extractRetryDelay(error: ApiError): number | undefined {
|
||||
if (error.info.type !== ApiErrorType.RATE_LIMIT || !error.info.responseHeaders) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const retryAfter = error.info.responseHeaders.get('Retry-After');
|
||||
if (!retryAfter) return undefined;
|
||||
|
||||
// Try parsing as seconds (number)
|
||||
const seconds = parseInt(retryAfter, 10);
|
||||
if (!isNaN(seconds)) return seconds;
|
||||
|
||||
// Try parsing as HTTP date
|
||||
const date = new Date(retryAfter);
|
||||
if (!isNaN(date.getTime())) {
|
||||
const now = Date.now();
|
||||
const delayMs = date.getTime() - now;
|
||||
return Math.max(0, Math.ceil(delayMs / 1000));
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private isSupportedMimeType(mimeType: MimeType): boolean {
|
||||
return this.SUPPORTED_MIMETYPES.includes(mimeType);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import { MimeType, toMimeType } from "Enums/MimeType";
|
|||
import { isTextFile } from "Enums/FileType";
|
||||
import { MimeTypeToFileTypes } from "Enums/FileTypeMimeTypeMapping";
|
||||
import { Exception } from "Helpers/Exception";
|
||||
import { ApiError, ApiErrorType } from "Types/ApiError";
|
||||
|
||||
export class Gemini extends BaseAIClass {
|
||||
|
||||
|
|
@ -137,7 +138,9 @@ export class Gemini extends BaseAIClass {
|
|||
yield* this.streamingService.streamRequest(
|
||||
`${AIProviderURL.Gemini}/${this.settingsService.settings.model}:streamGenerateContent?key=${this.apiKey}&alt=sse`,
|
||||
requestBody,
|
||||
(chunk: string) => this.parseStreamChunk(chunk)
|
||||
(chunk: string) => this.parseStreamChunk(chunk),
|
||||
undefined, // No additional headers
|
||||
(error) => this.extractRetryDelay(error)
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -342,6 +345,34 @@ export class Gemini extends BaseAIClass {
|
|||
return JSON.stringify(parts);
|
||||
}
|
||||
|
||||
private extractRetryDelay(error: ApiError): number | undefined {
|
||||
if (error.info.type !== ApiErrorType.RATE_LIMIT || !error.info.responseBody) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(error.info.responseBody) as {
|
||||
error?: {
|
||||
details?: Array<{ retryDelay?: string }>
|
||||
}
|
||||
};
|
||||
|
||||
const retryDelay = parsed.error?.details?.[0]?.retryDelay;
|
||||
if (!retryDelay) return undefined;
|
||||
|
||||
// Parse duration string (e.g., "60s", "1.5s")
|
||||
const match = retryDelay.match(/^(\d+\.?\d*)s$/);
|
||||
if (match) {
|
||||
const seconds = parseFloat(match[1]);
|
||||
return Math.ceil(seconds);
|
||||
}
|
||||
|
||||
return undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private isSupportedMimeType(mimeType: MimeType): boolean {
|
||||
return this.SUPPORTED_MIMETYPES.includes(mimeType);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import { fromString as aiFunctionFromString } from "Enums/AIFunction";
|
|||
import type { IAIFunctionDefinition } from "AIClasses/FunctionDefinitions/IAIFunctionDefinition";
|
||||
import type { ResponseEvent, ResponseOutputTextDelta, ResponseOutputItemDone, ResponseErrorEvent, ResponseFailedEvent, OpenAIFunctionTool, ResponsesAPIInput } from "./OpenAITypes";
|
||||
import { Exception } from "Helpers/Exception";
|
||||
import { ApiErrorType } from "Types/ApiError";
|
||||
import { ApiError, ApiErrorType } from "Types/ApiError";
|
||||
import { MimeType, toMimeType } from "Enums/MimeType";
|
||||
import { isTextFile } from "Enums/FileType";
|
||||
import { MimeTypeToFileTypes } from "Enums/FileTypeMimeTypeMapping";
|
||||
|
|
@ -60,7 +60,8 @@ export class OpenAI extends BaseAIClass {
|
|||
AIProviderURL.OpenAI,
|
||||
requestBody,
|
||||
(chunk: string) => this.parseStreamChunk(chunk),
|
||||
headers
|
||||
headers,
|
||||
(error) => this.extractRetryDelay(error)
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -348,6 +349,38 @@ export class OpenAI extends BaseAIClass {
|
|||
}]);
|
||||
}
|
||||
|
||||
private extractRetryDelay(error: ApiError): number | undefined {
|
||||
if (error.info.type !== ApiErrorType.RATE_LIMIT || !error.info.responseHeaders) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const headers = error.info.responseHeaders;
|
||||
|
||||
// Try x-ratelimit-reset-requests first (most common)
|
||||
const resetRequests = headers.get('x-ratelimit-reset-requests');
|
||||
if (resetRequests) {
|
||||
const resetTimestamp = parseInt(resetRequests, 10);
|
||||
if (!isNaN(resetTimestamp)) {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const delaySeconds = Math.max(0, resetTimestamp - now);
|
||||
return delaySeconds;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to x-ratelimit-reset-tokens
|
||||
const resetTokens = headers.get('x-ratelimit-reset-tokens');
|
||||
if (resetTokens) {
|
||||
const resetTimestamp = parseInt(resetTokens, 10);
|
||||
if (!isNaN(resetTimestamp)) {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const delaySeconds = Math.max(0, resetTimestamp - now);
|
||||
return delaySeconds;
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private isSupportedMimeType(mimeType: MimeType): boolean {
|
||||
return this.SUPPORTED_MIMETYPES.includes(mimeType);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ export class StreamingService {
|
|||
}
|
||||
|
||||
public async* streamRequest(url: string, requestBody: unknown, parseStreamChunk: (chunk: string) => IStreamChunk,
|
||||
additionalHeaders?: Record<string, string>): AsyncGenerator<IStreamChunk, void, unknown> {
|
||||
additionalHeaders?: Record<string, string>, extractRetryDelay?: (error: ApiError) => number | undefined): AsyncGenerator<IStreamChunk, void, unknown> {
|
||||
|
||||
let lastError: Error | null = null;
|
||||
|
||||
|
|
@ -61,7 +61,9 @@ export class StreamingService {
|
|||
return;
|
||||
}
|
||||
|
||||
await sleep(StreamingService.RETRY_DELAYS[attempt]);
|
||||
const delayMs = this.calculateRetryDelay(error, attempt, extractRetryDelay);
|
||||
Exception.warn(`Rate limit exceeded, waiting for ${delayMs}ms...`);
|
||||
await sleep(delayMs);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -86,7 +88,7 @@ export class StreamingService {
|
|||
|
||||
if (!response.ok) {
|
||||
const responseBody = await response.text();
|
||||
throw ApiError.fromResponse(response.status, response.statusText, responseBody);
|
||||
throw ApiError.fromResponse(response.status, response.statusText, responseBody, response.headers);
|
||||
}
|
||||
|
||||
return response;
|
||||
|
|
@ -178,4 +180,20 @@ export class StreamingService {
|
|||
return attempt < StreamingService.MAX_RETRIES;
|
||||
}
|
||||
|
||||
private calculateRetryDelay(error: unknown, attempt: number,
|
||||
extractRetryDelay?: (error: ApiError) => number | undefined
|
||||
): number {
|
||||
// Only use provider-specific delay for 429 rate limits
|
||||
if (error instanceof ApiError && error.info.type === ApiErrorType.RATE_LIMIT && extractRetryDelay) {
|
||||
const providerDelaySeconds = extractRetryDelay(error);
|
||||
if (providerDelaySeconds !== undefined) {
|
||||
// Convert to milliseconds
|
||||
return providerDelaySeconds * 1000;
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to exponential backoff
|
||||
return StreamingService.RETRY_DELAYS[attempt];
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -14,7 +14,8 @@ export interface ApiErrorInfo {
|
|||
message: string;
|
||||
userMessage: string;
|
||||
isRetryable: boolean;
|
||||
retryAfter?: number; // seconds
|
||||
responseHeaders?: Headers;
|
||||
responseBody?: string;
|
||||
}
|
||||
|
||||
export class ApiError extends Error {
|
||||
|
|
@ -27,7 +28,7 @@ export class ApiError extends Error {
|
|||
return error instanceof ApiError;
|
||||
}
|
||||
|
||||
static fromResponse(status: number, statusText: string, responseBody: string): ApiError {
|
||||
static fromResponse(status: number, statusText: string, responseBody: string, headers?: Headers): ApiError {
|
||||
let type: ApiErrorType;
|
||||
let userMessage: string;
|
||||
let isRetryable: boolean;
|
||||
|
|
@ -83,7 +84,9 @@ export class ApiError extends Error {
|
|||
statusCode: status,
|
||||
message,
|
||||
userMessage,
|
||||
isRetryable
|
||||
isRetryable,
|
||||
responseHeaders: headers,
|
||||
responseBody: responseBody
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue