diff --git a/controller/option.go b/controller/option.go index a2db95326..959f2f9b8 100644 --- a/controller/option.go +++ b/controller/option.go @@ -187,6 +187,15 @@ func UpdateOption(c *gin.Context) { }) return } + case "AutomaticRetryStatusCodes": + _, err = operation_setting.ParseHTTPStatusCodeRanges(option.Value.(string)) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } case "console_setting.api_info": err = console_setting.ValidateConsoleSettings(option.Value.(string), "ApiInfo") if err != nil { diff --git a/controller/relay.go b/controller/relay.go index 72ea3e24c..4fba947f7 100644 --- a/controller/relay.go +++ b/controller/relay.go @@ -21,6 +21,7 @@ import ( "github.com/QuantumNous/new-api/relay/helper" "github.com/QuantumNous/new-api/service" "github.com/QuantumNous/new-api/setting" + "github.com/QuantumNous/new-api/setting/operation_setting" "github.com/QuantumNous/new-api/types" "github.com/bytedance/gopkg/util/gopool" @@ -316,30 +317,14 @@ func shouldRetry(c *gin.Context, openaiErr *types.NewAPIError, retryTimes int) b if _, ok := c.Get("specific_channel_id"); ok { return false } - if openaiErr.StatusCode == http.StatusTooManyRequests { - return true - } - if openaiErr.StatusCode == 307 { - return true - } - if openaiErr.StatusCode/100 == 5 { - // 超时不重试 - if openaiErr.StatusCode == 504 || openaiErr.StatusCode == 524 { - return false - } - return true - } - if openaiErr.StatusCode == http.StatusBadRequest { + code := openaiErr.StatusCode + if code >= 200 && code < 300 { return false } - if openaiErr.StatusCode == 408 { - // azure处理超时不重试 - return false + if code < 100 || code > 599 { + return true } - if openaiErr.StatusCode/100 == 2 { - return false - } - return true + return operation_setting.ShouldRetryByStatusCode(code) } func processChannelError(c *gin.Context, channelError types.ChannelError, err *types.NewAPIError) { diff --git a/model/option.go b/model/option.go index 24cf7862d..e268cf577 100644 --- a/model/option.go +++ b/model/option.go @@ -144,6 +144,7 @@ func InitOptionMap() { common.OptionMap["StreamCacheQueueLength"] = strconv.Itoa(setting.StreamCacheQueueLength) common.OptionMap["AutomaticDisableKeywords"] = operation_setting.AutomaticDisableKeywordsToString() common.OptionMap["AutomaticDisableStatusCodes"] = operation_setting.AutomaticDisableStatusCodesToString() + common.OptionMap["AutomaticRetryStatusCodes"] = operation_setting.AutomaticRetryStatusCodesToString() common.OptionMap["ExposeRatioEnabled"] = strconv.FormatBool(ratio_setting.IsExposeRatioEnabled()) // 自动添加所有注册的模型配置 @@ -447,6 +448,8 @@ func updateOptionMap(key string, value string) (err error) { operation_setting.AutomaticDisableKeywordsFromString(value) case "AutomaticDisableStatusCodes": err = operation_setting.AutomaticDisableStatusCodesFromString(value) + case "AutomaticRetryStatusCodes": + err = operation_setting.AutomaticRetryStatusCodesFromString(value) case "StreamCacheQueueLength": setting.StreamCacheQueueLength, _ = strconv.Atoi(value) case "PayMethods": diff --git a/setting/operation_setting/status_code_ranges.go b/setting/operation_setting/status_code_ranges.go index 7a763008e..698c87c91 100644 --- a/setting/operation_setting/status_code_ranges.go +++ b/setting/operation_setting/status_code_ranges.go @@ -14,19 +14,20 @@ type StatusCodeRange struct { var AutomaticDisableStatusCodeRanges = []StatusCodeRange{{Start: 401, End: 401}} +// Default behavior matches legacy hardcoded retry rules in controller/relay.go shouldRetry: +// retry for 1xx, 3xx, 4xx(except 400/408), 5xx(except 504/524), and no retry for 2xx. +var AutomaticRetryStatusCodeRanges = []StatusCodeRange{ + {Start: 100, End: 199}, + {Start: 300, End: 399}, + {Start: 401, End: 407}, + {Start: 409, End: 499}, + {Start: 500, End: 503}, + {Start: 505, End: 523}, + {Start: 525, End: 599}, +} + func AutomaticDisableStatusCodesToString() string { - if len(AutomaticDisableStatusCodeRanges) == 0 { - return "" - } - parts := make([]string, 0, len(AutomaticDisableStatusCodeRanges)) - for _, r := range AutomaticDisableStatusCodeRanges { - if r.Start == r.End { - parts = append(parts, strconv.Itoa(r.Start)) - continue - } - parts = append(parts, fmt.Sprintf("%d-%d", r.Start, r.End)) - } - return strings.Join(parts, ",") + return statusCodeRangesToString(AutomaticDisableStatusCodeRanges) } func AutomaticDisableStatusCodesFromString(s string) error { @@ -39,10 +40,46 @@ func AutomaticDisableStatusCodesFromString(s string) error { } func ShouldDisableByStatusCode(code int) bool { + return shouldMatchStatusCodeRanges(AutomaticDisableStatusCodeRanges, code) +} + +func AutomaticRetryStatusCodesToString() string { + return statusCodeRangesToString(AutomaticRetryStatusCodeRanges) +} + +func AutomaticRetryStatusCodesFromString(s string) error { + ranges, err := ParseHTTPStatusCodeRanges(s) + if err != nil { + return err + } + AutomaticRetryStatusCodeRanges = ranges + return nil +} + +func ShouldRetryByStatusCode(code int) bool { + return shouldMatchStatusCodeRanges(AutomaticRetryStatusCodeRanges, code) +} + +func statusCodeRangesToString(ranges []StatusCodeRange) string { + if len(ranges) == 0 { + return "" + } + parts := make([]string, 0, len(ranges)) + for _, r := range ranges { + if r.Start == r.End { + parts = append(parts, strconv.Itoa(r.Start)) + continue + } + parts = append(parts, fmt.Sprintf("%d-%d", r.Start, r.End)) + } + return strings.Join(parts, ",") +} + +func shouldMatchStatusCodeRanges(ranges []StatusCodeRange, code int) bool { if code < 100 || code > 599 { return false } - for _, r := range AutomaticDisableStatusCodeRanges { + for _, r := range ranges { if code < r.Start { return false } diff --git a/setting/operation_setting/status_code_ranges_test.go b/setting/operation_setting/status_code_ranges_test.go index 1712efd75..5801824ac 100644 --- a/setting/operation_setting/status_code_ranges_test.go +++ b/setting/operation_setting/status_code_ranges_test.go @@ -50,3 +50,30 @@ func TestShouldDisableByStatusCode(t *testing.T) { require.True(t, ShouldDisableByStatusCode(500)) require.False(t, ShouldDisableByStatusCode(200)) } + +func TestShouldRetryByStatusCode(t *testing.T) { + orig := AutomaticRetryStatusCodeRanges + t.Cleanup(func() { AutomaticRetryStatusCodeRanges = orig }) + + AutomaticRetryStatusCodeRanges = []StatusCodeRange{ + {Start: 429, End: 429}, + {Start: 500, End: 599}, + } + + require.True(t, ShouldRetryByStatusCode(429)) + require.True(t, ShouldRetryByStatusCode(500)) + require.False(t, ShouldRetryByStatusCode(400)) + require.False(t, ShouldRetryByStatusCode(200)) +} + +func TestShouldRetryByStatusCode_DefaultMatchesLegacyBehavior(t *testing.T) { + require.False(t, ShouldRetryByStatusCode(200)) + require.False(t, ShouldRetryByStatusCode(400)) + require.True(t, ShouldRetryByStatusCode(401)) + require.False(t, ShouldRetryByStatusCode(408)) + require.True(t, ShouldRetryByStatusCode(429)) + require.True(t, ShouldRetryByStatusCode(500)) + require.False(t, ShouldRetryByStatusCode(504)) + require.False(t, ShouldRetryByStatusCode(524)) + require.True(t, ShouldRetryByStatusCode(599)) +} diff --git a/web/src/components/settings/HttpStatusCodeRulesInput.jsx b/web/src/components/settings/HttpStatusCodeRulesInput.jsx new file mode 100644 index 000000000..361bc19e6 --- /dev/null +++ b/web/src/components/settings/HttpStatusCodeRulesInput.jsx @@ -0,0 +1,71 @@ +/* +Copyright (C) 2025 QuantumNous + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +For commercial licensing, please contact support@quantumnous.com +*/ + +import React from 'react'; +import { Form, Tag, Typography } from '@douyinfe/semi-ui'; + +export default function HttpStatusCodeRulesInput(props) { + const { Text } = Typography; + const { + label, + field, + placeholder, + extraText, + onChange, + parsed, + invalidText, + } = props; + + return ( + <> + + {parsed?.ok && parsed.tokens?.length > 0 && ( +
+ {parsed.tokens.map((token) => ( + + {token} + + ))} +
+ )} + {!parsed?.ok && ( + + {invalidText} + {parsed?.invalidTokens && parsed.invalidTokens.length > 0 + ? `: ${parsed.invalidTokens.join(', ')}` + : ''} + + )} + + ); +} + diff --git a/web/src/components/settings/OperationSetting.jsx b/web/src/components/settings/OperationSetting.jsx index 4a77bcf10..9ee5fd007 100644 --- a/web/src/components/settings/OperationSetting.jsx +++ b/web/src/components/settings/OperationSetting.jsx @@ -71,6 +71,7 @@ const OperationSetting = () => { AutomaticEnableChannelEnabled: false, AutomaticDisableKeywords: '', AutomaticDisableStatusCodes: '401', + AutomaticRetryStatusCodes: '100-199,300-399,401-407,409-499,500-503,505-523,525-599', 'monitor_setting.auto_test_channel_enabled': false, 'monitor_setting.auto_test_channel_minutes': 10 /* 签到设置 */, 'checkin_setting.enabled': false, diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index f6d55544d..88619a7ad 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -1925,6 +1925,8 @@ "自动禁用关键词": "Automatic disable keywords", "自动禁用状态码": "Auto-disable status codes", "自动禁用状态码格式不正确": "Invalid auto-disable status code format", + "自动重试状态码": "Auto-retry status codes", + "自动重试状态码格式不正确": "Invalid auto-retry status code format", "支持填写单个状态码或范围(含首尾),使用逗号分隔": "Supports single status codes or inclusive ranges; separate with commas", "例如:401, 403, 429, 500-599": "e.g. 401,403,429,500-599", "自动选择": "Auto Select", diff --git a/web/src/i18n/locales/zh.json b/web/src/i18n/locales/zh.json index e91f50a4e..1b6bea41f 100644 --- a/web/src/i18n/locales/zh.json +++ b/web/src/i18n/locales/zh.json @@ -1911,6 +1911,8 @@ "自动禁用关键词": "自动禁用关键词", "自动禁用状态码": "自动禁用状态码", "自动禁用状态码格式不正确": "自动禁用状态码格式不正确", + "自动重试状态码": "自动重试状态码", + "自动重试状态码格式不正确": "自动重试状态码格式不正确", "支持填写单个状态码或范围(含首尾),使用逗号分隔": "支持填写单个状态码或范围(含首尾),使用逗号分隔", "例如:401, 403, 429, 500-599": "例如:401,403,429,500-599", "自动选择": "自动选择", diff --git a/web/src/pages/Setting/Operation/SettingsMonitoring.jsx b/web/src/pages/Setting/Operation/SettingsMonitoring.jsx index 9715ef3cb..6e1743478 100644 --- a/web/src/pages/Setting/Operation/SettingsMonitoring.jsx +++ b/web/src/pages/Setting/Operation/SettingsMonitoring.jsx @@ -24,8 +24,6 @@ import { Form, Row, Spin, - Tag, - Typography, } from '@douyinfe/semi-ui'; import { compareObjects, @@ -34,13 +32,12 @@ import { showSuccess, showWarning, parseHttpStatusCodeRules, - verifyJSON, } from '../../../helpers'; import { useTranslation } from 'react-i18next'; +import HttpStatusCodeRulesInput from '../../../components/settings/HttpStatusCodeRulesInput'; export default function SettingsMonitoring(props) { const { t } = useTranslation(); - const { Text } = Typography; const [loading, setLoading] = useState(false); const [inputs, setInputs] = useState({ ChannelDisableThreshold: '', @@ -49,6 +46,7 @@ export default function SettingsMonitoring(props) { AutomaticEnableChannelEnabled: false, AutomaticDisableKeywords: '', AutomaticDisableStatusCodes: '401', + AutomaticRetryStatusCodes: '100-199,300-399,401-407,409-499,500-503,505-523,525-599', 'monitor_setting.auto_test_channel_enabled': false, 'monitor_setting.auto_test_channel_minutes': 10, }); @@ -57,6 +55,9 @@ export default function SettingsMonitoring(props) { const parsedAutoDisableStatusCodes = parseHttpStatusCodeRules( inputs.AutomaticDisableStatusCodes || '', ); + const parsedAutoRetryStatusCodes = parseHttpStatusCodeRules( + inputs.AutomaticRetryStatusCodes || '', + ); function onSubmit() { const updateArray = compareObjects(inputs, inputsRow); @@ -69,16 +70,24 @@ export default function SettingsMonitoring(props) { : ''; return showError(`${t('自动禁用状态码格式不正确')}${details}`); } + if (!parsedAutoRetryStatusCodes.ok) { + const details = + parsedAutoRetryStatusCodes.invalidTokens && + parsedAutoRetryStatusCodes.invalidTokens.length > 0 + ? `: ${parsedAutoRetryStatusCodes.invalidTokens.join(', ')}` + : ''; + return showError(`${t('自动重试状态码格式不正确')}${details}`); + } const requestQueue = updateArray.map((item) => { let value = ''; if (typeof inputs[item.key] === 'boolean') { value = String(inputs[item.key]); } else { - if (item.key === 'AutomaticDisableStatusCodes') { - value = parsedAutoDisableStatusCodes.normalized; - } else { - value = inputs[item.key]; - } + const normalizedMap = { + AutomaticDisableStatusCodes: parsedAutoDisableStatusCodes.normalized, + AutomaticRetryStatusCodes: parsedAutoRetryStatusCodes.normalized, + }; + value = normalizedMap[item.key] ?? inputs[item.key]; } return API.put('/api/option/', { key: item.key, @@ -233,7 +242,7 @@ export default function SettingsMonitoring(props) { - setInputs({ ...inputs, AutomaticDisableStatusCodes: value }) } + parsed={parsedAutoDisableStatusCodes} + invalidText={t('自动禁用状态码格式不正确')} /> - {parsedAutoDisableStatusCodes.ok && - parsedAutoDisableStatusCodes.tokens.length > 0 && ( -
- {parsedAutoDisableStatusCodes.tokens.map((token) => ( - - {token} - - ))} -
+ - {t('自动禁用状态码格式不正确')} - {parsedAutoDisableStatusCodes.invalidTokens && - parsedAutoDisableStatusCodes.invalidTokens.length > 0 - ? `: ${parsedAutoDisableStatusCodes.invalidTokens.join( - ', ', - )}` - : ''} - - )} + field={'AutomaticRetryStatusCodes'} + onChange={(value) => + setInputs({ ...inputs, AutomaticRetryStatusCodes: value }) + } + parsed={parsedAutoRetryStatusCodes} + invalidText={t('自动重试状态码格式不正确')} + />