Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 27 additions & 2 deletions src/actions/error-rules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -284,8 +284,18 @@ export async function updateErrorRuleAction(
};
}

// 直接使用 updates,不做额外处理
const processedUpdates = updates;
// 复制 updates,以便在需要时调整 isDefault 字段
const processedUpdates: typeof updates & { isDefault?: boolean } = {
...updates,
};

// 如果是默认规则,编辑时自动转换为自定义规则
// 这样用户的修改不会被"同步规则"操作覆盖
let convertedFromDefault = false;
if (currentRule.isDefault) {
processedUpdates.isDefault = false;
convertedFromDefault = true;
}

const result = await repo.updateErrorRule(id, processedUpdates);

Expand All @@ -303,9 +313,17 @@ export async function updateErrorRuleAction(

revalidatePath("/settings/error-rules");

if (convertedFromDefault) {
logger.info("[ErrorRulesAction] Converted default rule to custom on edit", {
id,
userId: session.user.id,
});
}

logger.info("[ErrorRulesAction] Updated error rule", {
id,
updates,
convertedFromDefault,
userId: session.user.id,
});

Expand Down Expand Up @@ -563,6 +581,9 @@ export async function testErrorRuleAction(input: { message: string }): Promise<

/**
* 获取缓存统计信息
*
* 注意:此函数会确保缓存已初始化(懒加载),
* 避免在新的 worker 进程中返回空统计信息
*/
export async function getCacheStats() {
try {
Expand All @@ -571,6 +592,10 @@ export async function getCacheStats() {
return null;
}

// 确保缓存已初始化(懒加载)
// 解决重启后新 worker 进程中缓存为空的问题
await errorRuleDetector.ensureInitialized();

return errorRuleDetector.getStats();
} catch (error) {
logger.error("[ErrorRulesAction] Failed to get cache stats:", error);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,16 @@
"use client";

import { CheckCircle2, XCircle } from "lucide-react";
import { CheckCircle2, ChevronDown, XCircle } from "lucide-react";
import { useTranslations } from "next-intl";
import { useCallback, useMemo } from "react";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
Expand All @@ -15,15 +21,37 @@ type JsonValidationState =
| { state: "valid" }
| { state: "invalid"; message: string };

/** 默认的覆写响应模板 */
const DEFAULT_OVERRIDE_RESPONSE = `{
/** Claude 格式的覆写响应模板 */
const CLAUDE_OVERRIDE_TEMPLATE = `{
"type": "error",
"error": {
"type": "invalid_request_error",
"message": "Your custom error message here"
}
}`;

/** Gemini 格式的覆写响应模板 */
const GEMINI_OVERRIDE_TEMPLATE = `{
"error": {
"code": 400,
"message": "Your custom error message here",
"status": "INVALID_ARGUMENT"
}
}`;

/** OpenAI 格式的覆写响应模板 */
const OPENAI_OVERRIDE_TEMPLATE = `{
"error": {
"message": "Your custom error message here",
"type": "invalid_request_error",
"param": null,
"code": null
}
}`;

/** 默认的覆写响应模板(保持向后兼容) */
const DEFAULT_OVERRIDE_RESPONSE = CLAUDE_OVERRIDE_TEMPLATE;

interface OverrideSectionProps {
/** 输入框 ID 前缀,用于区分 add/edit 对话框 */
idPrefix: string;
Expand Down Expand Up @@ -61,14 +89,17 @@ export function OverrideSection({
}, [overrideResponse]);

/** 处理使用模板按钮点击 */
const handleUseTemplate = useCallback(() => {
// 如果输入框已有内容,弹出确认对话框
if (overrideResponse.trim().length > 0) {
const confirmed = window.confirm(t("errorRules.dialog.useTemplateConfirm"));
if (!confirmed) return;
}
onOverrideResponseChange(DEFAULT_OVERRIDE_RESPONSE);
}, [overrideResponse, onOverrideResponseChange, t]);
const handleUseTemplate = useCallback(
(template: string) => {
// 如果输入框已有内容,弹出确认对话框
if (overrideResponse.trim().length > 0) {
const confirmed = window.confirm(t("errorRules.dialog.useTemplateConfirm"));
if (!confirmed) return;
}
onOverrideResponseChange(template);
},
[overrideResponse, onOverrideResponseChange, t]
);

return (
<div className="rounded-lg border p-4 space-y-4">
Expand Down Expand Up @@ -105,15 +136,25 @@ export function OverrideSection({
{t("errorRules.dialog.invalidJson")}
</span>
)}
<Button
type="button"
variant="ghost"
size="sm"
className="h-6 text-xs"
onClick={handleUseTemplate}
>
{t("errorRules.dialog.useTemplate")}
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button type="button" variant="ghost" size="sm" className="h-6 text-xs">
{t("errorRules.dialog.useTemplate")}
<ChevronDown className="ml-1 h-3 w-3" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onSelect={() => handleUseTemplate(CLAUDE_OVERRIDE_TEMPLATE)}>
Claude API
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => handleUseTemplate(GEMINI_OVERRIDE_TEMPLATE)}>
Gemini API
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => handleUseTemplate(OPENAI_OVERRIDE_TEMPLATE)}>
OpenAI API
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
<Textarea
Expand Down
33 changes: 20 additions & 13 deletions src/app/v1/_lib/proxy/error-handler.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,13 @@
import { isValidErrorOverrideResponse } from "@/lib/error-override-validator";
import {
isClaudeErrorFormat,
isGeminiErrorFormat,
isOpenAIErrorFormat,
isValidErrorOverrideResponse,
} from "@/lib/error-override-validator";
import { logger } from "@/lib/logger";
import { ProxyStatusTracker } from "@/lib/proxy-status-tracker";
import { updateMessageRequestDetails, updateMessageRequestDuration } from "@/repository/message";
import { getErrorOverride, isRateLimitError, ProxyError, type RateLimitError } from "./errors";
import { getErrorOverrideAsync, isRateLimitError, ProxyError, type RateLimitError } from "./errors";
import { ProxyResponses } from "./responses";
import type { ProxySession } from "./session";

Expand Down Expand Up @@ -59,8 +64,9 @@ export class ProxyErrorHandler {
await ProxyErrorHandler.logErrorToDatabase(session, errorMessage, statusCode, null);

// 检测是否有覆写配置(响应体或状态码)
// 使用异步版本确保错误规则已加载
if (error instanceof Error) {
const override = getErrorOverride(error);
const override = await getErrorOverrideAsync(error);
if (override) {
// 运行时校验覆写状态码范围(400-599),防止数据库脏数据导致 Response 抛 RangeError
let validatedStatusCode = override.statusCode;
Expand Down Expand Up @@ -121,26 +127,27 @@ export class ProxyErrorHandler {
? overrideErrorObj.message
: errorMessage;

// 构建最终响应:注入 request_id(如果有),并确保 message 不为空
// 移除覆写配置中的 request_id,只使用上游的 request_id
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { request_id: _ignoredRequestId, ...overrideWithoutRequestId } =
override.response as Record<string, unknown>;

// 构建覆写响应体
// 设计原则:只输出用户配置的字段,不额外注入 request_id 等字段
// 唯一的特殊处理:message 为空时回退到原始错误消息
const responseBody = {
...overrideWithoutRequestId,
...override.response,
error: {
...overrideErrorObj,
message: overrideMessage,
},
...(safeRequestId ? { request_id: safeRequestId } : {}),
};

logger.info("ProxyErrorHandler: Applied error override response", {
original: errorMessage.substring(0, 200),
overrideType: override.response.error?.type,
format: isClaudeErrorFormat(override.response)
? "claude"
: isGeminiErrorFormat(override.response)
? "gemini"
: isOpenAIErrorFormat(override.response)
? "openai"
: "unknown",
statusCode: responseStatusCode,
hasRequestId: !!safeRequestId,
});

logger.error("ProxyErrorHandler: Request failed (overridden)", {
Expand Down
81 changes: 56 additions & 25 deletions src/app/v1/_lib/proxy/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -468,43 +468,67 @@ function extractErrorContentForDetection(error: Error): string {
const errorDetectionCache = new WeakMap<Error, ErrorDetectionResult>();

/**
* 检测错误规则(带缓存)
* 检测错误规则(异步版本,带缓存)
*
* 同一个 Error 对象只执行一次规则匹配,后续调用直接返回缓存结果
*
* 优化:避免在规则尚未初始化时缓存空结果
* - 如果规则已初始化,正常缓存结果
* - 如果规则未初始化,触发异步加载并返回同步结果(可能为空)
* 后续请求会自动获取正确的缓存结果
* 重要:此函数会确保错误规则在检测前已从数据库加载,
* 解决冷启动时规则未初始化导致检测失败的问题
*/
function detectErrorRuleOnce(error: Error): ErrorDetectionResult {
async function detectErrorRuleOnceAsync(error: Error): Promise<ErrorDetectionResult> {
const cached = errorDetectionCache.get(error);
if (cached) {
return cached;
}

const content = extractErrorContentForDetection(error);

// 避免在规则尚未初始化时缓存可能不完整的结果
if (!errorRuleDetector.hasInitialized()) {
// 触发异步初始化,但不阻塞当前请求
void errorRuleDetector
.detectAsync(content)
.then((result) => errorDetectionCache.set(error, result))
.catch(() => undefined);
// 使用 detectAsync 确保规则已加载
const result = await errorRuleDetector.detectAsync(content);
errorDetectionCache.set(error, result);
return result;
}

// 返回同步结果(可能为空),不缓存以允许后续请求重新检测
return errorRuleDetector.detect(content);
/**
* 向后兼容的同步检测入口,供尚未迁移的调用方/测试使用
*
* 若缓存已命中,直接返回结果;
* 若缓存尚未初始化,会立即返回当前同步检测结果,
* 并在后台触发一次 detectErrorRuleOnceAsync 以完成加载并填充缓存。
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Medium Potential race condition in error detection caching

Why this is a problem: When errorRuleDetector is not initialized and detectErrorRuleOnce is called, the code triggers an async initialization but returns a synchronous result that isn't cached. If multiple concurrent requests hit this code path before initialization completes, they will all execute errorRuleDetector.detect(content) without caching, potentially leading to inconsistent behavior. Additionally, the .catch(() => undefined) silently swallows errors from the async detection, making debugging difficult.

Suggested fix:

function detectErrorRuleOnce(error: Error): ErrorDetectionResult {
  const cached = errorDetectionCache.get(error);
  if (cached) {
    return cached;
  }

  const content = extractErrorContentForDetection(error);

  // Always use sync detection and cache the result
  // The async initialization is handled internally by the detector
  const result = errorRuleDetector.detect(content);
  
  // Only cache if the detector has been initialized to avoid caching incomplete results
  if (errorRuleDetector.hasInitialized()) {
    errorDetectionCache.set(error, result);
  }
  
  return result;
}

*
* 注意:生产代码路径(forwarder、error-handler)应使用异步版本
* isNonRetryableClientErrorAsync 以确保规则已加载
*/
export function isNonRetryableClientError(error: Error): boolean {
const cached = errorDetectionCache.get(error);
if (cached) {
return cached.matched;
}

const content = extractErrorContentForDetection(error);
const result = errorRuleDetector.detect(content);
errorDetectionCache.set(error, result);
return result;

// 只有规则已初始化时才缓存结果,避免缓存可能不完整的结果
if (errorRuleDetector.hasInitialized()) {
errorDetectionCache.set(error, result);
} else {
// 触发异步初始化,后续调用会获取正确结果
void detectErrorRuleOnceAsync(error).catch(() => undefined);
}

return result.matched;
}

export function isNonRetryableClientError(error: Error): boolean {
/**
* 检测是否为不可重试的客户端输入错误(异步版本)
*
* 此函数会确保错误规则已加载后再进行检测
* 生产代码路径(forwarder、error-handler)应优先使用此版本
*/
export async function isNonRetryableClientErrorAsync(error: Error): Promise<boolean> {
// 使用缓存的检测结果,避免重复执行规则匹配
return detectErrorRuleOnce(error).matched;
const result = await detectErrorRuleOnceAsync(error);
return result.matched;
}

/**
Expand All @@ -518,20 +542,24 @@ export interface ErrorOverrideResult {
}

/**
* 检测错误并返回覆写配置(如果配置了
* 检测错误并返回覆写配置(异步版本
*
* 用于在返回错误响应时应用覆写,将复杂的上游错误转换为友好的用户提示
* 支持三种覆写模式:
* 1. 仅覆写响应体
* 2. 仅覆写状态码
* 3. 同时覆写响应体和状态码
*
* 此函数会确保错误规则已加载后再进行检测
*
* @param error - 错误对象
* @returns 覆写配置(如果配置了响应体或状态码),否则返回 undefined
*/
export function getErrorOverride(error: Error): ErrorOverrideResult | undefined {
export async function getErrorOverrideAsync(
error: Error
): Promise<ErrorOverrideResult | undefined> {
// 使用缓存的检测结果,避免重复执行规则匹配
const result = detectErrorRuleOnce(error);
const result = await detectErrorRuleOnceAsync(error);

// 只要配置了响应体或状态码,就返回覆写配置
if (result.matched && (result.overrideResponse || result.overrideStatusCode)) {
Expand Down Expand Up @@ -635,7 +663,7 @@ export function isRateLimitError(error: unknown): error is RateLimitError {
}

/**
* 判断错误类型
* 判断错误类型(异步版本)
*
* 分类规则(优先级从高到低):
* 1. 客户端主动中断(AbortError 或 error.code === 'ECONNRESET' 且 statusCode === 499)
Expand All @@ -660,17 +688,20 @@ export function isRateLimitError(error: unknown): error is RateLimitError {
* → 不应计入供应商熔断器(不是供应商服务不可用)
* → 应先重试1次当前供应商(可能是临时网络抖动)
*
* 此函数会确保错误规则已加载后再进行检测
*
* @param error - 捕获的错误对象
* @returns 错误分类(CLIENT_ABORT、NON_RETRYABLE_CLIENT_ERROR、PROVIDER_ERROR 或 SYSTEM_ERROR)
*/
export function categorizeError(error: Error): ErrorCategory {
export async function categorizeErrorAsync(error: Error): Promise<ErrorCategory> {
// 优先级 1: 客户端中断检测(优先级最高)- 使用统一的精确检测函数
if (isClientAbortError(error)) {
return ErrorCategory.CLIENT_ABORT; // 客户端主动中断
}

// 优先级 2: 不可重试的客户端输入错误检测(白名单模式)
if (isNonRetryableClientError(error)) {
// 使用异步版本确保错误规则已加载
if (await isNonRetryableClientErrorAsync(error)) {
return ErrorCategory.NON_RETRYABLE_CLIENT_ERROR; // 客户端输入错误
}

Expand Down
Loading
Loading