feat(account): configurable pool-mode same-account retry status codes

Pool mode currently retries the same account for a fixed set of
upstream HTTP statuses: 401, 403, 429. Some upstream pool deployments
also need same-account retry for transient provider/proxy statuses
such as 502, 503, 520, 529, but hard-coding more statuses changes
behavior for everyone.

Add a per-account credentials option `pool_mode_retry_status_codes`
that lets admins choose which upstream HTTP status codes trigger
same-account retry in pool mode:

- Unset (default): preserve the current 401/403/429 default
- Explicit list: override the defaults with the configured codes
- Codes normalized to the 100-599 range, deduplicated, sorted

The standalone `isPoolModeRetryableStatus` helper is kept as the
default-only fallback. All 15 gateway call sites switch to the new
`Account.IsPoolModeRetryableStatus` method so behavior is preserved
for accounts that do not configure the new field.

Frontend admin UI gains a "Retry Status Codes" comma-separated input
under the pool-mode section in both Create/Edit account modals
(en + zh i18n).

Fixes #2731

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
StarryKira
2026-05-26 11:24:25 -07:00
co-authored by Claude Opus 4.7
parent f7ac5e5931
commit 21033dceb9
14 changed files with 422 additions and 21 deletions
@@ -1290,6 +1290,18 @@
}}
</p>
</div>
<div v-if="poolModeEnabled" class="mt-3">
<label class="input-label">{{ t('admin.accounts.poolModeRetryStatusCodes') }}</label>
<input
v-model="poolModeRetryStatusCodesInput"
type="text"
class="input"
:placeholder="DEFAULT_POOL_MODE_RETRY_STATUS_CODES.join(', ')"
/>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.accounts.poolModeRetryStatusCodesHint', { default: DEFAULT_POOL_MODE_RETRY_STATUS_CODES.join(', ') }) }}
</p>
</div>
</div>
<!-- Custom Error Codes Section -->
@@ -1635,6 +1647,18 @@
}}
</p>
</div>
<div v-if="poolModeEnabled" class="mt-3">
<label class="input-label">{{ t('admin.accounts.poolModeRetryStatusCodes') }}</label>
<input
v-model="poolModeRetryStatusCodesInput"
type="text"
class="input"
:placeholder="DEFAULT_POOL_MODE_RETRY_STATUS_CODES.join(', ')"
/>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.accounts.poolModeRetryStatusCodesHint', { default: DEFAULT_POOL_MODE_RETRY_STATUS_CODES.join(', ') }) }}
</p>
</div>
</div>
</div>
@@ -3297,8 +3321,27 @@ const modelRestrictionMode = ref<'whitelist' | 'mapping'>('whitelist')
const allowedModels = ref<string[]>([])
const DEFAULT_POOL_MODE_RETRY_COUNT = 3
const MAX_POOL_MODE_RETRY_COUNT = 10
const DEFAULT_POOL_MODE_RETRY_STATUS_CODES = [401, 403, 429]
const poolModeEnabled = ref(false)
const poolModeRetryCount = ref(DEFAULT_POOL_MODE_RETRY_COUNT)
const poolModeRetryStatusCodesInput = ref('')
function parsePoolModeRetryStatusCodes(input: string): number[] {
if (!input || !input.trim()) return []
const seen = new Set<number>()
const out: number[] = []
for (const token of input.split(/[,\s]+/)) {
const trimmed = token.trim()
if (!trimmed) continue
const n = Number(trimmed)
if (!Number.isFinite(n) || !Number.isInteger(n)) continue
if (n < 100 || n > 599) continue
if (seen.has(n)) continue
seen.add(n)
out.push(n)
}
return out.sort((a, b) => a - b)
}
const customErrorCodesEnabled = ref(false)
const selectedErrorCodes = ref<number[]>([])
const customErrorCodeInput = ref<number | null>(null)
@@ -4068,6 +4111,7 @@ const resetForm = () => {
})
poolModeEnabled.value = false
poolModeRetryCount.value = DEFAULT_POOL_MODE_RETRY_COUNT
poolModeRetryStatusCodesInput.value = ''
customErrorCodesEnabled.value = false
selectedErrorCodes.value = []
customErrorCodeInput.value = null
@@ -4350,6 +4394,10 @@ const handleSubmit = async () => {
if (poolModeEnabled.value) {
credentials.pool_mode = true
credentials.pool_mode_retry_count = normalizePoolModeRetryCount(poolModeRetryCount.value)
const parsedRetryStatusCodes = parsePoolModeRetryStatusCodes(poolModeRetryStatusCodesInput.value)
if (parsedRetryStatusCodes.length > 0) {
credentials.pool_mode_retry_status_codes = parsedRetryStatusCodes
}
}
applyInterceptWarmup(credentials, interceptWarmupRequests.value, 'create')
@@ -4460,6 +4508,10 @@ const handleSubmit = async () => {
if (poolModeEnabled.value) {
credentials.pool_mode = true
credentials.pool_mode_retry_count = normalizePoolModeRetryCount(poolModeRetryCount.value)
const parsedRetryStatusCodes = parsePoolModeRetryStatusCodes(poolModeRetryStatusCodesInput.value)
if (parsedRetryStatusCodes.length > 0) {
credentials.pool_mode_retry_status_codes = parsedRetryStatusCodes
}
}
// Add custom error codes if enabled
@@ -305,6 +305,18 @@
}}
</p>
</div>
<div v-if="poolModeEnabled" class="mt-3">
<label class="input-label">{{ t('admin.accounts.poolModeRetryStatusCodes') }}</label>
<input
v-model="poolModeRetryStatusCodesInput"
type="text"
class="input"
:placeholder="DEFAULT_POOL_MODE_RETRY_STATUS_CODES.join(', ')"
/>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.accounts.poolModeRetryStatusCodesHint', { default: DEFAULT_POOL_MODE_RETRY_STATUS_CODES.join(', ') }) }}
</p>
</div>
</div>
<!-- Custom Error Codes Section -->
@@ -973,6 +985,18 @@
}}
</p>
</div>
<div v-if="poolModeEnabled" class="mt-3">
<label class="input-label">{{ t('admin.accounts.poolModeRetryStatusCodes') }}</label>
<input
v-model="poolModeRetryStatusCodesInput"
type="text"
class="input"
:placeholder="DEFAULT_POOL_MODE_RETRY_STATUS_CODES.join(', ')"
/>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.accounts.poolModeRetryStatusCodesHint', { default: DEFAULT_POOL_MODE_RETRY_STATUS_CODES.join(', ') }) }}
</p>
</div>
</div>
</div>
@@ -2317,8 +2341,42 @@ const modelRestrictionMode = ref<'whitelist' | 'mapping'>('whitelist')
const allowedModels = ref<string[]>([])
const DEFAULT_POOL_MODE_RETRY_COUNT = 3
const MAX_POOL_MODE_RETRY_COUNT = 10
const DEFAULT_POOL_MODE_RETRY_STATUS_CODES = [401, 403, 429]
const poolModeEnabled = ref(false)
const poolModeRetryCount = ref(DEFAULT_POOL_MODE_RETRY_COUNT)
const poolModeRetryStatusCodesInput = ref('')
function parsePoolModeRetryStatusCodes(input: string): number[] {
if (!input || !input.trim()) return []
const seen = new Set<number>()
const out: number[] = []
for (const token of input.split(/[,\s]+/)) {
const trimmed = token.trim()
if (!trimmed) continue
const n = Number(trimmed)
if (!Number.isFinite(n) || !Number.isInteger(n)) continue
if (n < 100 || n > 599) continue
if (seen.has(n)) continue
seen.add(n)
out.push(n)
}
return out.sort((a, b) => a - b)
}
function formatPoolModeRetryStatusCodes(value: unknown): string {
if (!Array.isArray(value)) return ''
const out: number[] = []
const seen = new Set<number>()
for (const v of value) {
const n = typeof v === 'string' ? Number(v.trim()) : Number(v)
if (!Number.isFinite(n) || !Number.isInteger(n)) continue
if (n < 100 || n > 599) continue
if (seen.has(n)) continue
seen.add(n)
out.push(n)
}
return out.sort((a, b) => a - b).join(', ')
}
const customErrorCodesEnabled = ref(false)
const selectedErrorCodes = ref<number[]>([])
const customErrorCodeInput = ref<number | null>(null)
@@ -2807,6 +2865,7 @@ const syncFormFromAccount = (newAccount: Account | null) => {
poolModeRetryCount.value = normalizePoolModeRetryCount(
Number(credentials.pool_mode_retry_count ?? DEFAULT_POOL_MODE_RETRY_COUNT)
)
poolModeRetryStatusCodesInput.value = formatPoolModeRetryStatusCodes(credentials.pool_mode_retry_status_codes)
// Load custom error codes
customErrorCodesEnabled.value = credentials.custom_error_codes_enabled === true
@@ -2834,6 +2893,7 @@ const syncFormFromAccount = (newAccount: Account | null) => {
poolModeEnabled.value = bedrockCreds.pool_mode === true
const retryCount = bedrockCreds.pool_mode_retry_count
poolModeRetryCount.value = (typeof retryCount === 'number' && retryCount >= 0) ? retryCount : DEFAULT_POOL_MODE_RETRY_COUNT
poolModeRetryStatusCodesInput.value = formatPoolModeRetryStatusCodes(bedrockCreds.pool_mode_retry_status_codes)
// Load quota limits for bedrock
const bedrockExtra = (newAccount.extra as Record<string, unknown>) || {}
@@ -2876,6 +2936,7 @@ const syncFormFromAccount = (newAccount: Account | null) => {
}
poolModeEnabled.value = false
poolModeRetryCount.value = DEFAULT_POOL_MODE_RETRY_COUNT
poolModeRetryStatusCodesInput.value = ''
customErrorCodesEnabled.value = false
selectedErrorCodes.value = []
}
@@ -3427,9 +3488,16 @@ const handleSubmit = async () => {
if (poolModeEnabled.value) {
newCredentials.pool_mode = true
newCredentials.pool_mode_retry_count = normalizePoolModeRetryCount(poolModeRetryCount.value)
const parsedRetryStatusCodes = parsePoolModeRetryStatusCodes(poolModeRetryStatusCodesInput.value)
if (parsedRetryStatusCodes.length > 0) {
newCredentials.pool_mode_retry_status_codes = parsedRetryStatusCodes
} else {
delete newCredentials.pool_mode_retry_status_codes
}
} else {
delete newCredentials.pool_mode
delete newCredentials.pool_mode_retry_count
delete newCredentials.pool_mode_retry_status_codes
}
// Add custom error codes if enabled
@@ -3545,9 +3613,16 @@ const handleSubmit = async () => {
if (poolModeEnabled.value) {
newCredentials.pool_mode = true
newCredentials.pool_mode_retry_count = normalizePoolModeRetryCount(poolModeRetryCount.value)
const parsedRetryStatusCodes = parsePoolModeRetryStatusCodes(poolModeRetryStatusCodesInput.value)
if (parsedRetryStatusCodes.length > 0) {
newCredentials.pool_mode_retry_status_codes = parsedRetryStatusCodes
} else {
delete newCredentials.pool_mode_retry_status_codes
}
} else {
delete newCredentials.pool_mode
delete newCredentials.pool_mode_retry_count
delete newCredentials.pool_mode_retry_status_codes
}
// Model mapping
+3
View File
@@ -3410,6 +3410,9 @@ export default {
poolModeRetryCount: 'Same-Account Retries',
poolModeRetryCountHint:
'Only applies in pool mode. Use 0 to disable in-place retry. Default {default}, maximum {max}.',
poolModeRetryStatusCodes: 'Retry Status Codes',
poolModeRetryStatusCodesHint:
'Comma-separated HTTP status codes (100-599) that trigger same-account retry in pool mode. Leave blank to use defaults ({default}).',
customErrorCodes: 'Custom Error Codes',
customErrorCodesHint: 'Only stop scheduling for selected error codes',
customErrorCodesWarning:
+2
View File
@@ -3553,6 +3553,8 @@ export default {
'启用后,上游 429/403/401 错误将自动重试而不标记账号限流或错误,适用于上游指向另一个 sub2api 实例的场景。',
poolModeRetryCount: '同账号重试次数',
poolModeRetryCountHint: '仅在池模式下生效。0 表示不原地重试;默认 {default},最大 {max}。',
poolModeRetryStatusCodes: '同账号重试状态码',
poolModeRetryStatusCodesHint: '仅在池模式下生效。以英文逗号分隔的 HTTP 状态码(100-599),命中时触发同账号重试。留空使用默认值({default})。',
customErrorCodes: '自定义错误码',
customErrorCodesHint: '仅对选中的错误码停止调度',
customErrorCodesWarning: '仅选中的错误码会停止调度,其他错误将返回 500。',