mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-08 02:17:46 +08:00
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:
@@ -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>
|
||||
|
||||
+130
@@ -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,
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
+4
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
+8
-3
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user