feat(proxy): implement streaming timeout control with validation

- Add first byte timeout (0 or 1-180s) for streaming requests
- Add idle timeout (0 or 60-600s) for streaming data gaps
- Add non-streaming timeout (0 or 60-1800s) for total request
- Implement timeout logic in response processor
- Add 1800s global timeout fallback when disabled
- Add database schema migration for timeout fields
- Add i18n translations for timeout settings
This commit is contained in:
YoVinchen
2025-12-24 10:02:01 +08:00
parent c6f4a54c98
commit dbdaf35770
13 changed files with 713 additions and 37 deletions
@@ -10,6 +10,7 @@ import {
useCircuitBreakerConfig,
useUpdateCircuitBreakerConfig,
} from "@/lib/query/failover";
import { useProxyConfig } from "@/hooks/useProxyConfig";
export interface AutoFailoverConfigPanelProps {
enabled?: boolean;
@@ -26,6 +27,12 @@ export function AutoFailoverConfigPanel({
const { t } = useTranslation();
const { data: config, isLoading, error } = useCircuitBreakerConfig();
const updateConfig = useUpdateCircuitBreakerConfig();
const {
config: proxyConfig,
isLoading: isProxyLoading,
updateConfig: updateProxyConfig,
isUpdating: isProxyUpdating,
} = useProxyConfig();
const [formData, setFormData] = useState({
failureThreshold: 5,
@@ -34,6 +41,11 @@ export function AutoFailoverConfigPanel({
errorRateThreshold: 0.5,
minRequests: 10,
});
const [timeoutConfig, setTimeoutConfig] = useState({
streaming_first_byte_timeout: 30,
streaming_idle_timeout: 60,
non_streaming_timeout: 600,
});
useEffect(() => {
if (config) {
@@ -43,6 +55,17 @@ export function AutoFailoverConfigPanel({
}
}, [config]);
useEffect(() => {
if (proxyConfig) {
setTimeoutConfig({
streaming_first_byte_timeout:
proxyConfig.streaming_first_byte_timeout ?? 30,
streaming_idle_timeout: proxyConfig.streaming_idle_timeout ?? 60,
non_streaming_timeout: proxyConfig.non_streaming_timeout ?? 300,
});
}
}, [proxyConfig]);
const handleSave = async () => {
try {
await updateConfig.mutateAsync({
@@ -52,6 +75,15 @@ export function AutoFailoverConfigPanel({
errorRateThreshold: formData.errorRateThreshold,
minRequests: formData.minRequests,
});
if (proxyConfig) {
await updateProxyConfig({
...proxyConfig,
streaming_first_byte_timeout:
timeoutConfig.streaming_first_byte_timeout,
streaming_idle_timeout: timeoutConfig.streaming_idle_timeout,
non_streaming_timeout: timeoutConfig.non_streaming_timeout,
});
}
toast.success(
t("proxy.autoFailover.configSaved", "自动故障转移配置已保存"),
{ closeButton: true },
@@ -69,6 +101,14 @@ export function AutoFailoverConfigPanel({
...config,
});
}
if (proxyConfig) {
setTimeoutConfig({
streaming_first_byte_timeout:
proxyConfig.streaming_first_byte_timeout ?? 30,
streaming_idle_timeout: proxyConfig.streaming_idle_timeout ?? 60,
non_streaming_timeout: proxyConfig.non_streaming_timeout ?? 300,
});
}
};
if (isLoading) {
@@ -255,20 +295,134 @@ export function AutoFailoverConfigPanel({
</div>
</div>
{/* 代理请求超时配置 */}
<div className="space-y-4 rounded-lg border border-white/10 bg-muted/30 p-4">
<h4 className="text-sm font-semibold">
{t("proxy.settings.timeout.title", {
defaultValue: "超时设置",
})}
</h4>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="space-y-2">
<Label htmlFor="streamingFirstByteTimeout">
{t("proxy.settings.fields.streamingFirstByteTimeout.label", {
defaultValue: "流式首字超时(秒)",
})}
</Label>
<Input
id="streamingFirstByteTimeout"
type="number"
min="0"
max="180"
value={timeoutConfig.streaming_first_byte_timeout}
onChange={(e) => {
const val = parseInt(e.target.value) || 0;
if (val === 0 || (val >= 1 && val <= 180)) {
setTimeoutConfig({
...timeoutConfig,
streaming_first_byte_timeout: val,
});
}
}}
disabled={!enabled || isProxyLoading || isProxyUpdating}
/>
<p className="text-xs text-muted-foreground">
{t(
"proxy.settings.fields.streamingFirstByteTimeout.description",
"等待首个数据块的最大时间(0 禁用,范围 1-180 秒)",
)}
</p>
</div>
<div className="space-y-2">
<Label htmlFor="streamingIdleTimeout">
{t("proxy.settings.fields.streamingIdleTimeout.label", {
defaultValue: "流式静默超时(秒)",
})}
</Label>
<Input
id="streamingIdleTimeout"
type="number"
min="0"
max="600"
value={timeoutConfig.streaming_idle_timeout}
onChange={(e) => {
const val = parseInt(e.target.value) || 0;
if (val === 0 || (val >= 60 && val <= 600)) {
setTimeoutConfig({
...timeoutConfig,
streaming_idle_timeout: val,
});
}
}}
disabled={!enabled || isProxyLoading || isProxyUpdating}
/>
<p className="text-xs text-muted-foreground">
{t(
"proxy.settings.fields.streamingIdleTimeout.description",
"数据块之间的最大间隔(0 禁用,范围 60-600 秒)",
)}
</p>
</div>
<div className="space-y-2">
<Label htmlFor="nonStreamingTimeout">
{t("proxy.settings.fields.nonStreamingTimeout.label", {
defaultValue: "非流式超时(秒)",
})}
</Label>
<Input
id="nonStreamingTimeout"
type="number"
min="0"
max="1800"
value={timeoutConfig.non_streaming_timeout}
onChange={(e) => {
const val = parseInt(e.target.value) || 0;
if (val === 0 || (val >= 60 && val <= 1800)) {
setTimeoutConfig({
...timeoutConfig,
non_streaming_timeout: val,
});
}
}}
disabled={!enabled || isProxyLoading || isProxyUpdating}
/>
<p className="text-xs text-muted-foreground">
{t(
"proxy.settings.fields.nonStreamingTimeout.description",
"非流式请求的总超时时间(0 禁用,范围 60-1800 秒)",
)}
</p>
</div>
</div>
</div>
{/* 操作按钮 */}
<div className="flex justify-end gap-3 pt-2">
<Button
variant="outline"
onClick={handleReset}
disabled={updateConfig.isPending || !enabled}
disabled={
updateConfig.isPending ||
isProxyLoading ||
isProxyUpdating ||
!enabled
}
>
{t("common.reset", "重置")}
</Button>
<Button
onClick={handleSave}
disabled={updateConfig.isPending || !enabled}
disabled={
updateConfig.isPending ||
isProxyLoading ||
isProxyUpdating ||
!enabled
}
>
{updateConfig.isPending ? (
{updateConfig.isPending || isProxyUpdating ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
{t("common.saving", "保存中...")}
+166 -16
View File
@@ -34,29 +34,45 @@ type ProxyConfigForm = Pick<
| "max_retries"
| "request_timeout"
| "enable_logging"
| "streaming_first_byte_timeout"
| "streaming_idle_timeout"
| "non_streaming_timeout"
>;
const createProxyConfigSchema = (t: TFunction) => {
const requestTimeoutSchema = z
// 流式首字节超时:0 或 1-180 秒
const streamingFirstByteSchema = z
.number()
.min(
0,
t("proxy.settings.validation.timeoutNonNegative", {
defaultValue: "超时时间不能为负数",
}),
)
.max(
600,
t("proxy.settings.validation.timeoutMax", {
defaultValue: "超时时间最多600秒",
}),
)
.refine((value) => value === 0 || value >= 10, {
message: t("proxy.settings.validation.timeoutRange", {
defaultValue: "请输入 0 或 10-600 之间的数值",
.min(0)
.refine((val) => val === 0 || (val >= 1 && val <= 180), {
message: t("proxy.settings.validation.streamingFirstByteRange", {
defaultValue: "请输入 0 或 1-180 之间的数值",
}),
});
// 流式静默期超时:0 或 60-600 秒
const streamingIdleSchema = z
.number()
.min(0)
.refine((val) => val === 0 || (val >= 60 && val <= 600), {
message: t("proxy.settings.validation.streamingIdleRange", {
defaultValue: "请输入 0 或 60-600 之间的数值",
}),
});
// 非流式总超时:0 或 60-1800 秒
const nonStreamingSchema = z
.number()
.min(0)
.refine((val) => val === 0 || (val >= 60 && val <= 1800), {
message: t("proxy.settings.validation.nonStreamingRange", {
defaultValue: "请输入 0 或 60-1800 之间的数值",
}),
});
// 旧版请求超时(兼容)
const requestTimeoutSchema = z.number().min(0);
return z.object({
listen_address: z.string().regex(
/^(\d{1,3}\.){3}\d{1,3}$/,
@@ -94,6 +110,9 @@ const createProxyConfigSchema = (t: TFunction) => {
),
request_timeout: requestTimeoutSchema,
enable_logging: z.boolean(),
streaming_first_byte_timeout: streamingFirstByteSchema,
streaming_idle_timeout: streamingIdleSchema,
non_streaming_timeout: nonStreamingSchema,
});
};
@@ -120,6 +139,9 @@ export function ProxySettingsDialog({
max_retries: 3,
request_timeout: 300,
enable_logging: true,
streaming_first_byte_timeout: 30,
streaming_idle_timeout: 60,
non_streaming_timeout: 600,
},
});
@@ -132,6 +154,9 @@ export function ProxySettingsDialog({
max_retries: config.max_retries,
request_timeout: config.request_timeout,
enable_logging: config.enable_logging,
streaming_first_byte_timeout: config.streaming_first_byte_timeout ?? 30,
streaming_idle_timeout: config.streaming_idle_timeout ?? 60,
non_streaming_timeout: config.non_streaming_timeout ?? 300,
});
}
}, [config, form]);
@@ -395,6 +420,131 @@ export function ProxySettingsDialog({
)}
/>
</section>
<section className="space-y-4 rounded-xl border border-white/10 glass-card p-6">
<div>
<h3 className="text-base font-semibold text-foreground">
{t("proxy.settings.timeout.title", {
defaultValue: "超时设置",
})}
</h3>
<p className="text-sm text-muted-foreground">
{t("proxy.settings.timeout.description", {
defaultValue: "配置流式和非流式请求的超时时间。",
})}
</p>
</div>
<div className="grid gap-4 md:grid-cols-3">
<FormField
control={form.control}
name="streaming_first_byte_timeout"
render={({ field }) => (
<FormItem>
<FormLabel>
{t(
"proxy.settings.fields.streamingFirstByteTimeout.label",
{
defaultValue: "流式首字超时(秒)",
},
)}
</FormLabel>
<FormControl>
<Input
type="number"
inputMode="numeric"
{...field}
onChange={(e) =>
field.onChange(parseInt(e.target.value, 10) || 0)
}
placeholder="30"
disabled={isLoading}
/>
</FormControl>
<FormDescription>
{t(
"proxy.settings.fields.streamingFirstByteTimeout.description",
{
defaultValue: "等待首个数据块的最大时间",
},
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="streaming_idle_timeout"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("proxy.settings.fields.streamingIdleTimeout.label", {
defaultValue: "流式静默超时(秒)",
})}
</FormLabel>
<FormControl>
<Input
type="number"
inputMode="numeric"
{...field}
onChange={(e) =>
field.onChange(parseInt(e.target.value, 10) || 0)
}
placeholder="60"
disabled={isLoading}
/>
</FormControl>
<FormDescription>
{t(
"proxy.settings.fields.streamingIdleTimeout.description",
{
defaultValue: "数据块之间的最大间隔",
},
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="non_streaming_timeout"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("proxy.settings.fields.nonStreamingTimeout.label", {
defaultValue: "非流式超时(秒)",
})}
</FormLabel>
<FormControl>
<Input
type="number"
inputMode="numeric"
{...field}
onChange={(e) =>
field.onChange(parseInt(e.target.value, 10) || 0)
}
placeholder="300"
disabled={isLoading}
/>
</FormControl>
<FormDescription>
{t(
"proxy.settings.fields.nonStreamingTimeout.description",
{
defaultValue: "非流式请求的总超时时间",
},
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</div>
</section>
</form>
</Form>
</div>
+70
View File
@@ -981,6 +981,76 @@
}
},
"settings": {
"title": "Proxy Service Settings",
"description": "Configure local proxy server listening address, port and runtime parameters. Changes take effect immediately after saving.",
"alert": {
"autoApply": "Changes will be automatically synced to the running proxy service without manual restart."
},
"basic": {
"title": "Basic Settings",
"description": "Configure proxy service listening address and port."
},
"advanced": {
"title": "Advanced Parameters",
"description": "Control request stability and logging."
},
"timeout": {
"title": "Timeout Settings",
"description": "Configure timeout for streaming and non-streaming requests."
},
"fields": {
"listenAddress": {
"label": "Listen Address",
"placeholder": "127.0.0.1",
"description": "IP address the proxy server listens on (recommended: 127.0.0.1)"
},
"listenPort": {
"label": "Listen Port",
"placeholder": "5000",
"description": "Port number the proxy server listens on (1024 ~ 65535)"
},
"maxRetries": {
"label": "Max Retries",
"placeholder": "3",
"description": "Number of retries on request failure (0 ~ 10)"
},
"requestTimeout": {
"label": "Request Timeout (sec)",
"placeholder": "0 (unlimited) or 300",
"description": "Maximum wait time for a single request (0 = unlimited, or 10 ~ 600 seconds)"
},
"enableLogging": {
"label": "Enable Logging",
"description": "Log all proxy requests for troubleshooting"
},
"streamingFirstByteTimeout": {
"label": "Streaming First Byte Timeout (sec)",
"description": "Maximum time to wait for the first data chunk"
},
"streamingIdleTimeout": {
"label": "Streaming Idle Timeout (sec)",
"description": "Maximum interval between data chunks"
},
"nonStreamingTimeout": {
"label": "Non-Streaming Timeout (sec)",
"description": "Total timeout for non-streaming requests"
}
},
"validation": {
"addressInvalid": "Please enter a valid IP address",
"portMin": "Port must be greater than 1024",
"portMax": "Port must be less than 65535",
"retryMin": "Retry count cannot be negative",
"retryMax": "Retry count cannot exceed 10",
"timeoutNonNegative": "Timeout cannot be negative",
"timeoutMax": "Timeout cannot exceed 600 seconds",
"timeoutRange": "Please enter 0 or a value between 10-600",
"streamingTimeoutMin": "Timeout must be at least 5 seconds",
"streamingTimeoutMax": "Timeout cannot exceed 300 seconds"
},
"actions": {
"save": "Save Configuration"
},
"toast": {
"saved": "Proxy configuration saved",
"saveFailed": "Save failed: {{error}}"
+70
View File
@@ -981,6 +981,76 @@
}
},
"settings": {
"title": "プロキシサービス設定",
"description": "ローカルプロキシサーバーのリッスンアドレス、ポート、実行パラメータを設定します。保存後すぐに反映されます。",
"alert": {
"autoApply": "変更は実行中のプロキシサービスに自動的に同期され、手動での再起動は不要です。"
},
"basic": {
"title": "基本設定",
"description": "プロキシサービスのリッスンアドレスとポートを設定します。"
},
"advanced": {
"title": "詳細パラメータ",
"description": "リクエストの安定性とログ記録を制御します。"
},
"timeout": {
"title": "タイムアウト設定",
"description": "ストリーミングと非ストリーミングリクエストのタイムアウトを設定します。"
},
"fields": {
"listenAddress": {
"label": "リッスンアドレス",
"placeholder": "127.0.0.1",
"description": "プロキシサーバーがリッスンするIPアドレス(推奨: 127.0.0.1"
},
"listenPort": {
"label": "リッスンポート",
"placeholder": "5000",
"description": "プロキシサーバーがリッスンするポート番号(1024 ~ 65535"
},
"maxRetries": {
"label": "最大リトライ回数",
"placeholder": "3",
"description": "リクエスト失敗時のリトライ回数(0 ~ 10)"
},
"requestTimeout": {
"label": "リクエストタイムアウト(秒)",
"placeholder": "0(無制限)または 300",
"description": "単一リクエストの最大待機時間(0 = 無制限、または 10 ~ 600 秒)"
},
"enableLogging": {
"label": "ログ記録を有効化",
"description": "トラブルシューティングのためにすべてのプロキシリクエストを記録"
},
"streamingFirstByteTimeout": {
"label": "ストリーミング初回バイトタイムアウト(秒)",
"description": "最初のデータチャンクを待つ最大時間"
},
"streamingIdleTimeout": {
"label": "ストリーミングアイドルタイムアウト(秒)",
"description": "データチャンク間の最大間隔"
},
"nonStreamingTimeout": {
"label": "非ストリーミングタイムアウト(秒)",
"description": "非ストリーミングリクエストの総タイムアウト"
}
},
"validation": {
"addressInvalid": "有効なIPアドレスを入力してください",
"portMin": "ポートは1024より大きい必要があります",
"portMax": "ポートは65535より小さい必要があります",
"retryMin": "リトライ回数は負の値にできません",
"retryMax": "リトライ回数は10を超えることはできません",
"timeoutNonNegative": "タイムアウトは負の値にできません",
"timeoutMax": "タイムアウトは600秒を超えることはできません",
"timeoutRange": "0または10-600の間の値を入力してください",
"streamingTimeoutMin": "タイムアウトは少なくとも5秒必要です",
"streamingTimeoutMax": "タイムアウトは300秒を超えることはできません"
},
"actions": {
"save": "設定を保存"
},
"toast": {
"saved": "プロキシ設定を保存しました",
"saveFailed": "保存に失敗しました: {{error}}"
+70
View File
@@ -981,6 +981,76 @@
}
},
"settings": {
"title": "代理服务设置",
"description": "配置本地代理服务器的监听地址、端口和运行参数,保存后立即生效。",
"alert": {
"autoApply": "保存后将自动同步到正在运行的代理服务,无需手动重启。"
},
"basic": {
"title": "基础设置",
"description": "配置代理服务监听的地址与端口。"
},
"advanced": {
"title": "高级参数",
"description": "控制请求的稳定性和日志记录。"
},
"timeout": {
"title": "超时设置",
"description": "配置流式和非流式请求的超时时间。"
},
"fields": {
"listenAddress": {
"label": "监听地址",
"placeholder": "127.0.0.1",
"description": "代理服务器监听的 IP 地址(推荐 127.0.0.1"
},
"listenPort": {
"label": "监听端口",
"placeholder": "5000",
"description": "代理服务器监听的端口号(1024 ~ 65535"
},
"maxRetries": {
"label": "最大重试次数",
"placeholder": "3",
"description": "请求失败时的重试次数(0 ~ 10)"
},
"requestTimeout": {
"label": "请求超时(秒)",
"placeholder": "0(不限)或 300",
"description": "单个请求的最大等待时间(0 表示不限制,或设置 10 ~ 600 秒)"
},
"enableLogging": {
"label": "启用日志记录",
"description": "记录所有代理请求,便于排查问题"
},
"streamingFirstByteTimeout": {
"label": "流式首字超时(秒)",
"description": "等待首个数据块的最大时间"
},
"streamingIdleTimeout": {
"label": "流式静默超时(秒)",
"description": "数据块之间的最大间隔"
},
"nonStreamingTimeout": {
"label": "非流式超时(秒)",
"description": "非流式请求的总超时时间"
}
},
"validation": {
"addressInvalid": "请输入有效的IP地址",
"portMin": "端口必须大于1024",
"portMax": "端口必须小于65535",
"retryMin": "重试次数不能为负",
"retryMax": "重试次数不能超过10",
"timeoutNonNegative": "超时时间不能为负数",
"timeoutMax": "超时时间最多600秒",
"timeoutRange": "请输入 0 或 10-600 之间的数值",
"streamingTimeoutMin": "超时时间至少5秒",
"streamingTimeoutMax": "超时时间最多300秒"
},
"actions": {
"save": "保存配置"
},
"toast": {
"saved": "代理配置已保存",
"saveFailed": "保存失败: {{error}}"
+4
View File
@@ -5,6 +5,10 @@ export interface ProxyConfig {
request_timeout: number;
enable_logging: boolean;
live_takeover_active?: boolean;
// 超时配置
streaming_first_byte_timeout: number;
streaming_idle_timeout: number;
non_streaming_timeout: number;
}
export interface ProxyStatus {