feat(gateway): harden failover and payload handling

Retry pre-response transport failures across candidates with an explicit stop policy, and propagate end-to-end timing into usage records and UI diagnostics.

Remove legacy body, import, cookie, PII, and tunnel replay caps while preserving optional operator-configured gateway limits.
This commit is contained in:
elky
2026-07-30 01:03:27 +08:00
parent a97acc07fc
commit a04673a90d
80 changed files with 3640 additions and 1236 deletions
@@ -8,6 +8,33 @@
@update:model-value="handleClose"
>
<div class="space-y-5 max-h-[60vh] overflow-y-auto px-0.5 py-0.5 -mx-0.5">
<!-- 传输错误规则 -->
<div class="space-y-3">
<div>
<h3 class="text-sm font-medium">
传输错误
</h3>
<p class="text-xs text-muted-foreground mt-0.5">
这类错误没有可用于故障转移判断的上游 HTTP 状态码,因此不会命中下方的状态码规则
</p>
</div>
<div class="flex items-center justify-between gap-4 rounded-lg border bg-muted/50 p-3">
<div class="min-w-0 space-y-0.5">
<span class="text-sm font-medium">继续尝试下一候选</span>
<p class="text-xs leading-relaxed text-muted-foreground">
适用于 DNS 解析、TCP 连接、TLS 握手、代理/WARP 连接,以及响应提交前的连接重置或超时。默认开启;关闭后会立即返回网关错误。响应开始发送后无法切换候选。
</p>
</div>
<Switch
aria-label="传输错误时继续尝试下一候选"
class="shrink-0"
:model-value="continueOnTransportErrors"
@update:model-value="(value: boolean) => continueOnTransportErrors = value"
/>
</div>
</div>
<!-- 成功转移规则 -->
<div class="space-y-3">
<div class="flex items-start justify-between gap-3">
@@ -243,6 +270,7 @@ import {
Dialog,
Button,
Input,
Switch,
Textarea,
} from '@/components/ui'
import { AlignLeft, Code2, GitBranch, Plus, Trash2 } from 'lucide-vue-next'
@@ -263,6 +291,7 @@ const emit = defineEmits<{
const { success, error: showError } = useToast()
const saving = ref(false)
const continueOnTransportErrors = ref(true)
const successPatterns = ref<FailoverRuleItem[]>([])
const errorPatterns = ref<FailoverRuleItem[]>([])
@@ -284,6 +313,7 @@ const TOP_LEVEL_STOP_STATUS_CODE_KEYS = [
] as const
const MANAGED_FAILOVER_RULE_KEYS = [
'stop_on_transport_errors',
'success_failover_patterns',
'error_stop_patterns',
...TOP_LEVEL_STOP_STATUS_CODE_KEYS,
@@ -334,6 +364,9 @@ function buildNextFailoverRules(
if (filteredError.length > 0) {
nextRules.error_stop_patterns = filteredError
}
if (!continueOnTransportErrors.value) {
nextRules.stop_on_transport_errors = true
}
return Object.values(nextRules).some(hasPersistableFailoverValue)
? nextRules as FailoverRulesConfig
@@ -343,6 +376,7 @@ function buildNextFailoverRules(
watch(() => [props.open, props.provider], () => {
if (props.open && props.provider) {
const rules = props.provider.failover_rules
continueOnTransportErrors.value = rules?.stop_on_transport_errors !== true
successPatterns.value = (rules?.success_failover_patterns || []).map(r => ({
...r,
pattern: r.pattern || '',
@@ -1188,6 +1188,7 @@ const hasFailoverRules = computed(() => {
if (!rules) return false
return FAILOVER_RULE_ARRAY_KEYS.some(key => (rules[key]?.length || 0) > 0)
|| typeof rules.max_retries === 'number'
|| rules.stop_on_transport_errors === true
})
// Provider 级别代理配置状态
@@ -216,10 +216,11 @@
</Label>
<Input
id="max-transfer-count"
:model-value="form.max_transfer_count"
:model-value="form.max_transfer_count === 0 ? '' : form.max_transfer_count"
type="number"
min="0"
step="1"
:placeholder="legacyT('0 (不限制)')"
@update:model-value="(v) => form.max_transfer_count = parseNumberInput(v, { min: 0 }) ?? 0"
/>
</div>
@@ -233,10 +234,11 @@
</Label>
<Input
id="max-transfer-timeout-seconds"
:model-value="form.max_transfer_timeout_seconds"
:model-value="form.max_transfer_timeout_seconds === 0 ? '' : form.max_transfer_timeout_seconds"
type="number"
min="0"
step="1"
:placeholder="legacyT('0 (不限制)')"
@update:model-value="(v) => form.max_transfer_timeout_seconds = parseNumberInput(v, { min: 0 }) ?? 0"
/>
</div>
@@ -0,0 +1,130 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createApp, nextTick, type App } from 'vue'
import type { FailoverRulesConfig, ProviderWithEndpointsSummary } from '@/api/endpoints/types'
import FailoverRulesDialog from '../FailoverRulesDialog.vue'
const endpointMocks = vi.hoisted(() => ({
updateProvider: vi.fn(),
}))
vi.mock('@/api/endpoints', () => ({
updateProvider: endpointMocks.updateProvider,
}))
vi.mock('@/composables/useToast', () => ({
useToast: () => ({
success: vi.fn(),
error: vi.fn(),
}),
}))
const mountedApps: Array<{ app: App, root: HTMLElement }> = []
function makeProvider(failoverRules: FailoverRulesConfig | null): ProviderWithEndpointsSummary {
return {
id: 'provider-1',
failover_rules: failoverRules,
} as ProviderWithEndpointsSummary
}
function mountDialog(failoverRules: FailoverRulesConfig | null = null) {
const root = document.createElement('div')
document.body.appendChild(root)
const app = createApp(FailoverRulesDialog, {
open: true,
provider: makeProvider(failoverRules),
'onUpdate:open': vi.fn(),
})
app.mount(root)
mountedApps.push({ app, root })
}
async function settle() {
for (let index = 0; index < 4; index += 1) {
await Promise.resolve()
await nextTick()
}
}
function transportErrorSwitch(): HTMLButtonElement {
const control = document.body.querySelector<HTMLButtonElement>(
'[role="switch"][aria-label="传输错误时继续尝试下一候选"]',
)
if (!control) throw new Error('Missing transport-error failover switch')
return control
}
function clickSave() {
const button = [...document.body.querySelectorAll<HTMLButtonElement>('button')]
.find(candidate => candidate.textContent?.trim() === '保存')
if (!button) throw new Error('Missing save button')
button.click()
}
beforeEach(() => {
endpointMocks.updateProvider.mockReset()
endpointMocks.updateProvider.mockResolvedValue(makeProvider(null))
})
afterEach(() => {
for (const { app, root } of mountedApps.splice(0)) {
app.unmount()
root.remove()
}
document.body.innerHTML = ''
})
describe('FailoverRulesDialog transport errors', () => {
it('continues failover by default and explains why HTTP status rules do not apply', async () => {
mountDialog()
await settle()
expect(transportErrorSwitch().getAttribute('aria-checked')).toBe('true')
expect(document.body.textContent).toContain('没有可用于故障转移判断的上游 HTTP 状态码')
expect(document.body.textContent).toContain('DNS 解析')
expect(document.body.textContent).toContain('TCP 连接')
expect(document.body.textContent).toContain('TLS 握手')
expect(document.body.textContent).toContain('响应提交前的连接重置或超时')
expect(document.body.textContent).toContain('响应开始发送后无法切换候选')
})
it('persists stop_on_transport_errors when continuing is disabled', async () => {
mountDialog()
await settle()
transportErrorSwitch().click()
await nextTick()
expect(transportErrorSwitch().getAttribute('aria-checked')).toBe('false')
clickSave()
await settle()
expect(endpointMocks.updateProvider).toHaveBeenCalledWith('provider-1', {
failover_rules: {
stop_on_transport_errors: true,
},
})
})
it('loads a stop rule and removes only that rule when continuing is enabled', async () => {
mountDialog({
max_retries: 2,
stop_on_transport_errors: true,
})
await settle()
expect(transportErrorSwitch().getAttribute('aria-checked')).toBe('false')
transportErrorSwitch().click()
await nextTick()
clickSave()
await settle()
expect(endpointMocks.updateProvider).toHaveBeenCalledWith('provider-1', {
failover_rules: {
max_retries: 2,
},
})
})
})
@@ -54,4 +54,8 @@ describe('ProviderDetailDrawer loading priorities', () => {
expect(source).toContain('v-if="open && batchAssignDialogOpen && provider"')
expect(source).toContain('v-if="open && failoverRulesDialogOpen"')
})
it('marks a transport-error stop policy as a configured failover rule', () => {
expect(source).toContain('rules.stop_on_transport_errors === true')
})
})
@@ -177,15 +177,20 @@ describe('ProviderFormDialog transfer limits', () => {
)
})
it('defaults missing legacy values to explicit zero', async () => {
it('shows zero limits as unlimited placeholders while submitting explicit zero', async () => {
mountDialog(makeProvider({
max_transfer_count: undefined,
max_transfer_timeout_seconds: undefined,
}))
await settle()
expect(document.body.querySelector<HTMLInputElement>('#max-transfer-count')?.value).toBe('0')
expect(document.body.querySelector<HTMLInputElement>('#max-transfer-timeout-seconds')?.value).toBe('0')
const countInput = document.body.querySelector<HTMLInputElement>('#max-transfer-count')
const timeoutInput = document.body.querySelector<HTMLInputElement>('#max-transfer-timeout-seconds')
expect(countInput?.value).toBe('')
expect(countInput?.placeholder).toBe('0 (不限制)')
expect(timeoutInput?.value).toBe('')
expect(timeoutInput?.placeholder).toBe('0 (不限制)')
clickButton('保存')
await settle()