Browse Source

feat: customizable automatic retry status codes

Seefs 1 month ago
parent
commit
4bffc249d6

+ 9 - 0
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 {

+ 6 - 21
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 {
-		return false
-	}
-	if openaiErr.StatusCode == 408 {
-		// azure处理超时不重试
+	code := openaiErr.StatusCode
+	if code >= 200 && code < 300 {
 		return false
 	}
-	if openaiErr.StatusCode/100 == 2 {
-		return false
+	if code < 100 || code > 599 {
+		return true
 	}
-	return true
+	return operation_setting.ShouldRetryByStatusCode(code)
 }
 
 func processChannelError(c *gin.Context, channelError types.ChannelError, err *types.NewAPIError) {

+ 3 - 0
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":

+ 50 - 13
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
 		}

+ 27 - 0
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))
+}

+ 71 - 0
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 <https://www.gnu.org/licenses/>.
+
+For commercial licensing, please contact [email protected]
+*/
+
+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 (
+    <>
+      <Form.Input
+        label={label}
+        placeholder={placeholder}
+        extraText={extraText}
+        field={field}
+        onChange={onChange}
+      />
+      {parsed?.ok && parsed.tokens?.length > 0 && (
+        <div
+          style={{
+            display: 'flex',
+            flexWrap: 'wrap',
+            gap: 8,
+            marginTop: 8,
+          }}
+        >
+          {parsed.tokens.map((token) => (
+            <Tag key={token} size='small'>
+              {token}
+            </Tag>
+          ))}
+        </div>
+      )}
+      {!parsed?.ok && (
+        <Text type='danger' style={{ display: 'block', marginTop: 8 }}>
+          {invalidText}
+          {parsed?.invalidTokens && parsed.invalidTokens.length > 0
+            ? `: ${parsed.invalidTokens.join(', ')}`
+            : ''}
+        </Text>
+      )}
+    </>
+  );
+}
+

+ 1 - 0
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,

+ 2 - 0
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",

+ 2 - 0
web/src/i18n/locales/zh.json

@@ -1911,6 +1911,8 @@
     "自动禁用关键词": "自动禁用关键词",
     "自动禁用状态码": "自动禁用状态码",
     "自动禁用状态码格式不正确": "自动禁用状态码格式不正确",
+    "自动重试状态码": "自动重试状态码",
+    "自动重试状态码格式不正确": "自动重试状态码格式不正确",
     "支持填写单个状态码或范围(含首尾),使用逗号分隔": "支持填写单个状态码或范围(含首尾),使用逗号分隔",
     "例如:401, 403, 429, 500-599": "例如:401,403,429,500-599",
     "自动选择": "自动选择",

+ 33 - 37
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) {
             </Row>
             <Row gutter={16}>
               <Col xs={24} sm={16}>
-                <Form.Input
+                <HttpStatusCodeRulesInput
                   label={t('自动禁用状态码')}
                   placeholder={t('例如:401, 403, 429, 500-599')}
                   extraText={t(
@@ -243,35 +252,22 @@ export default function SettingsMonitoring(props) {
                   onChange={(value) =>
                     setInputs({ ...inputs, AutomaticDisableStatusCodes: value })
                   }
+                  parsed={parsedAutoDisableStatusCodes}
+                  invalidText={t('自动禁用状态码格式不正确')}
                 />
-                {parsedAutoDisableStatusCodes.ok &&
-                  parsedAutoDisableStatusCodes.tokens.length > 0 && (
-                    <div
-                      style={{
-                        display: 'flex',
-                        flexWrap: 'wrap',
-                        gap: 8,
-                        marginTop: 8,
-                      }}
-                    >
-                      {parsedAutoDisableStatusCodes.tokens.map((token) => (
-                        <Tag key={token} size='small'>
-                          {token}
-                        </Tag>
-                      ))}
-                    </div>
+                <HttpStatusCodeRulesInput
+                  label={t('自动重试状态码')}
+                  placeholder={t('例如:401, 403, 429, 500-599')}
+                  extraText={t(
+                    '支持填写单个状态码或范围(含首尾),使用逗号分隔',
                   )}
-                {!parsedAutoDisableStatusCodes.ok && (
-                  <Text type='danger' style={{ display: 'block', marginTop: 8 }}>
-                    {t('自动禁用状态码格式不正确')}
-                    {parsedAutoDisableStatusCodes.invalidTokens &&
-                    parsedAutoDisableStatusCodes.invalidTokens.length > 0
-                      ? `: ${parsedAutoDisableStatusCodes.invalidTokens.join(
-                          ', ',
-                        )}`
-                      : ''}
-                  </Text>
-                )}
+                  field={'AutomaticRetryStatusCodes'}
+                  onChange={(value) =>
+                    setInputs({ ...inputs, AutomaticRetryStatusCodes: value })
+                  }
+                  parsed={parsedAutoRetryStatusCodes}
+                  invalidText={t('自动重试状态码格式不正确')}
+                />
                 <Form.TextArea
                   label={t('自动禁用关键词')}
                   placeholder={t('一行一个,不区分大小写')}