Files
OmniRoute/open-sse/utils/error.ts
diegosouzapw bbecbccb0a fix(cli): harden setup, doctor, and backup workflows
Hide admin password entry during setup, make doctor degrade to warnings
when source-only runtime checks are unavailable, and improve stop
behavior by attempting graceful shutdown before force killing ports.

Also use SQLite's backup API for safer snapshots under WAL, align CLI
key writes with the current provider_connections schema, and include
follow-on compatibility fixes for GLM provider detection, stream error
sanitization, and auth-aware test coverage.
2026-05-11 09:13:49 -03:00

374 lines
11 KiB
TypeScript

import { CORS_HEADERS } from "./cors.ts";
import { getDefaultErrorMessage, getErrorInfo } from "../config/errorConfig.ts";
import { normalizePayloadForLog } from "@/lib/logPayloads";
import type { ModelCooldownErrorPayload } from "@/types";
/**
* Sanitize an error message to prevent stack trace exposure in API responses.
* Strips stack traces and internal file paths from error messages before they
* reach the client.
*/
interface ErrorResponseBody {
error: {
message: string;
type?: string;
code?: string;
};
}
function sanitizeErrorMessage(message: unknown): string {
const str = typeof message === "string" ? message : String(message ?? "");
// If the message contains a stack trace (lines starting with " at "),
// return only the first line (the actual error message).
const firstLine = str.split("\n")[0] || str;
return firstLine;
}
/**
* Build OpenAI-compatible error response body
* @param {number} statusCode - HTTP status code
* @param {string} message - Error message
* @returns {object} Error response object
*/
export function buildErrorBody(statusCode: number, message: string): ErrorResponseBody {
const errorInfo = getErrorInfo(statusCode);
return {
error: {
message: message || getDefaultErrorMessage(statusCode),
type: errorInfo.type,
code: errorInfo.code,
},
};
}
/**
* Create error Response object (for non-streaming)
* @param {number} statusCode - HTTP status code
* @param {string} message - Error message
* @returns {Response} HTTP Response object
*/
export function errorResponse(statusCode, message) {
return new Response(JSON.stringify(buildErrorBody(statusCode, sanitizeErrorMessage(message))), {
status: statusCode,
headers: {
"Content-Type": "application/json",
},
});
}
/**
* Write error to SSE stream (for streaming)
* @param {WritableStreamDefaultWriter} writer - Stream writer
* @param {number} statusCode - HTTP status code
* @param {string} message - Error message
*/
export async function writeStreamError(writer, statusCode, message) {
const errorBody = buildErrorBody(statusCode, sanitizeErrorMessage(message));
const encoder = new TextEncoder();
await writer.write(encoder.encode(`data: ${JSON.stringify(errorBody)}\n\n`));
}
function normalizeRetryAfterSeconds(retryAfter?: string | number | Date | null): number {
if (typeof retryAfter === "number" && Number.isFinite(retryAfter)) {
if (retryAfter > 0 && retryAfter < 1_000_000_000) {
return Math.max(Math.ceil(retryAfter), 1);
}
const retryTimeMs = new Date(retryAfter).getTime();
if (Number.isFinite(retryTimeMs)) {
return Math.max(Math.ceil((retryTimeMs - Date.now()) / 1000), 1);
}
}
if (retryAfter instanceof Date || typeof retryAfter === "string") {
const retryTimeMs = new Date(retryAfter).getTime();
if (Number.isFinite(retryTimeMs)) {
return Math.max(Math.ceil((retryTimeMs - Date.now()) / 1000), 1);
}
}
return 1;
}
/**
* Parse Antigravity error message to extract retry time
* Example: "You have exhausted your capacity on this model. Your quota will reset after 2h7m23s."
* @param {string} message - Error message
* @returns {number|null} Retry time in milliseconds, or null if not found
*/
export function parseAntigravityRetryTime(message) {
if (typeof message !== "string") return null;
// Match patterns like: 2h7m23s, 5m30s, 45s, 1h20m, etc.
const match = message.match(/reset after (\d+h)?(\d+m)?(\d+s)?/i);
if (!match) return null;
let totalMs = 0;
// Extract hours
if (match[1]) {
const hours = parseInt(match[1]);
totalMs += hours * 60 * 60 * 1000;
}
// Extract minutes
if (match[2]) {
const minutes = parseInt(match[2]);
totalMs += minutes * 60 * 1000;
}
// Extract seconds
if (match[3]) {
const seconds = parseInt(match[3]);
totalMs += seconds * 1000;
}
return totalMs > 0 ? totalMs : null;
}
/**
* Parse upstream provider error response
* @param {Response} response - Fetch response from provider
* @param {string} provider - Provider name (for Antigravity-specific parsing)
* @returns {Promise<{statusCode: number, message: string, retryAfterMs: number|null, responseBody: unknown}>}
*/
export async function parseUpstreamError(response, provider = null) {
let message = "";
let retryAfterMs = null;
let responseBody = null;
let errorCode = undefined;
let errorType = undefined;
try {
const text = await response.text();
responseBody = normalizePayloadForLog(text);
// Try parse as JSON
try {
const json = JSON.parse(text);
message = json.error?.message || json.message || json.error || text;
errorCode = json.error?.code || json.code;
errorType = json.error?.type || json.type;
} catch {
message = text;
}
} catch {
message = `Upstream error: ${response.status}`;
responseBody = { _rawText: message };
}
const messageStr = typeof message === "string" ? message : JSON.stringify(message);
const retryAfterHeader = response.headers?.get?.("retry-after");
if (retryAfterHeader && !retryAfterMs) {
const retryAfterSec = Number.parseInt(retryAfterHeader, 10);
if (Number.isFinite(retryAfterSec) && retryAfterSec > 0) {
retryAfterMs = retryAfterSec * 1000;
} else {
const retryAfterDate = new Date(retryAfterHeader).getTime();
if (Number.isFinite(retryAfterDate) && retryAfterDate > Date.now()) {
retryAfterMs = retryAfterDate - Date.now();
}
}
}
// Parse Antigravity-specific retry time from error message
if (provider === "antigravity" && response.status === 429) {
retryAfterMs = parseAntigravityRetryTime(messageStr);
}
// Also parse retry time for other providers (Qwen, etc.) with "quota will reset after XhYmZs" format
if (response.status === 429 && !retryAfterMs) {
retryAfterMs = parseAntigravityRetryTime(messageStr);
}
// Generic providers: "Please retry after 20s"
if (response.status === 429 && !retryAfterMs) {
const retryMatch = messageStr.match(/retry\s+after\s+(\d+)\s*s/i);
if (retryMatch) {
retryAfterMs = Number.parseInt(retryMatch[1], 10) * 1000;
}
}
// Cap maximum retry time at 24 hours to prevent infinite wait
const MAX_RETRY_MS = 24 * 60 * 60 * 1000;
if (retryAfterMs && retryAfterMs > MAX_RETRY_MS) {
retryAfterMs = MAX_RETRY_MS;
}
const responseHeaders: Record<string, string> | null = response.headers
? Object.fromEntries(response.headers.entries())
: null;
return {
statusCode: response.status,
message: messageStr,
errorCode,
errorType,
retryAfterMs,
responseBody,
responseHeaders,
};
}
/**
* Create error result for chatCore handler
* @param {number} statusCode - HTTP status code
* @param {string} message - Error message
* @param {number|null} retryAfterMs - Optional retry-after time in milliseconds
* @returns {{ success: false, status: number, error: string, response: Response, retryAfterMs?: number }}
*/
export function createErrorResult(
statusCode: number,
message: string,
retryAfterMs: number | null = null,
errorCode?: string,
errorType?: string
) {
const body = buildErrorBody(statusCode, message);
if (errorCode) {
body.error.code = errorCode;
}
if (errorType) {
body.error.type = errorType;
}
const result: {
success: false;
status: number;
error: string;
errorType?: string;
response: Response;
retryAfterMs?: number;
} = {
success: false,
status: statusCode,
error: message,
errorType,
response: new Response(JSON.stringify(body), {
status: statusCode,
headers: { "Content-Type": "application/json" },
}),
};
// Add retryAfterMs if available (for Antigravity quota errors)
if (retryAfterMs) {
result.retryAfterMs = retryAfterMs;
}
return result;
}
/**
* Create unavailable response when all accounts are rate limited
* @param {number} statusCode - Original error status code
* @param {string} message - Error message (without retry info)
* @param {string} retryAfter - ISO timestamp when earliest account becomes available
* @param {string} retryAfterHuman - Human-readable retry info e.g. "reset after 30s"
* @returns {Response}
*/
export function unavailableResponse(
statusCode: number,
message: string,
retryAfter?: string | number | Date | null,
retryAfterHuman?: string
) {
const retryAfterSec = normalizeRetryAfterSeconds(retryAfter);
const msg = retryAfterHuman ? `${message} (${retryAfterHuman})` : message;
return new Response(JSON.stringify({ error: { message: msg } }), {
status: statusCode,
headers: {
"Content-Type": "application/json",
"Retry-After": String(retryAfterSec),
},
});
}
export function providerCircuitOpenResponse(
provider: string,
retryAfter?: string | number | Date | null
) {
const retryAfterSec = normalizeRetryAfterSeconds(retryAfter);
return new Response(
JSON.stringify({
error: {
message: `Provider ${provider} circuit breaker is open`,
type: "server_error",
code: "provider_circuit_open",
provider,
retry_after: retryAfterSec,
},
}),
{
status: 503,
headers: {
"Content-Type": "application/json",
"Retry-After": String(retryAfterSec),
"X-OmniRoute-Provider-Breaker": "open",
},
}
);
}
export function buildModelCooldownBody({
model,
retryAfterSec,
}: {
model?: string | null;
retryAfterSec: number;
}): ModelCooldownErrorPayload {
const resolvedModel = typeof model === "string" && model.trim().length > 0 ? model.trim() : null;
return {
error: {
message: resolvedModel
? `All credentials for model ${resolvedModel} are cooling down`
: "All credentials for the requested model are cooling down",
type: "rate_limit_error",
code: "model_cooldown",
...(resolvedModel ? { model: resolvedModel } : {}),
reset_seconds: Math.max(Math.ceil(retryAfterSec), 1),
},
};
}
export function modelCooldownResponse({
model,
retryAfter,
}: {
model?: string | null;
retryAfter?: string | number | Date | null;
}) {
const retryAfterSec = normalizeRetryAfterSeconds(retryAfter);
return new Response(
JSON.stringify(
buildModelCooldownBody({
model,
retryAfterSec,
})
),
{
status: 429,
headers: {
"Content-Type": "application/json",
"Retry-After": String(retryAfterSec),
},
}
);
}
/**
* Format provider error with context
* @param {Error} error - Original error
* @param {string} provider - Provider name
* @param {string} model - Model name
* @param {number|string} statusCode - HTTP status code or error code
* @returns {string} Formatted error message
*/
export function formatProviderError(error, provider, model, statusCode) {
const code = statusCode || error.code || "FETCH_FAILED";
const message = error.message || "Unknown error";
return `[${code}]: ${message}`;
}