feat: sync deployed v1.0.0-rc.38-custom.42

Sync production source including routing, billing, Astra detection and public uptime. Update custom build instructions and license packaging.
This commit is contained in:
cneicy committed 2026-09-26 04:49:11 +08:00
1 parent aa6c3f50f4
commit ceddfd3d6b
828 files changed
+122253 -13702

No files matched your search

+190 -198
View File
@@ -25,12 +25,8 @@ import (
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/QuantumNous/new-api/relaykit/types"
"github.com/QuantumNous/new-api/service"
"github.com/QuantumNous/new-api/setting"
"github.com/QuantumNous/new-api/setting/operation_setting"
"github.com/bytedance/gopkg/util/gopool"
"github.com/samber/lo"
"github.com/gin-gonic/gin"
"github.com/gorilla/websocket"
)
@@ -77,6 +73,13 @@ func publicUpstreamErrorContent(statusCode int) string {
}
func clientFacingOpenAIError(err *types.NewAPIError, upstreamFailure bool, requestId string) types.OpenAIError {
if err.GetErrorCode() == relaycommon.ResponseGuardErrorCode {
return types.OpenAIError{
Message: common.MessageWithRequestId(err.Error(), requestId),
Type: "api_error",
Code: relaycommon.ResponseGuardErrorCode,
}
}
if upstreamFailure {
return types.OpenAIError{
Message: common.MessageWithRequestId(publicUpstreamErrorMessage, requestId),
@@ -122,7 +125,11 @@ func Relay(c *gin.Context, relayFormat types.RelayFormat) {
defer func() {
if newAPIError != nil {
service.RecordRequestPolicyTermination(c, newAPIError)
logger.LogError(c, fmt.Sprintf("relay error: %s", common.LocalLogPreview(newAPIError.Error())))
if c.GetBool("response_guard_error_sent") {
return
}
switch relayFormat {
case types.RelayFormatOpenAIRealtime:
helper.WssError(c, ws, clientFacingOpenAIError(newAPIError, upstreamFailure, requestId))
@@ -156,59 +163,25 @@ func Relay(c *gin.Context, relayFormat types.RelayFormat) {
return
}
needSensitiveCheck := setting.ShouldCheckPromptSensitive()
needCountToken := constant.CountToken
// Avoid building huge CombineText (strings.Join) when token counting and sensitive check are both disabled.
var meta *types.TokenCountMeta
if needSensitiveCheck || needCountToken {
meta = request.GetTokenCountMeta()
} else {
meta = fastTokenCountMetaForPricing(request)
}
if needSensitiveCheck && meta != nil {
contains, words := service.CheckSensitiveText(meta.CombineText)
if contains {
logger.LogWarn(c, fmt.Sprintf("user sensitive words detected: %s", strings.Join(words, ", ")))
newAPIError = types.NewError(err, types.ErrorCodeSensitiveWordsDetected)
return
}
}
tokens, err := service.EstimateRequestToken(c, meta, relayInfo)
if err != nil {
newAPIError = types.NewError(err, types.ErrorCodeCountTokenFailed)
return
}
relayInfo.SetEstimatePromptTokens(tokens)
priceData, err := helper.ModelPriceHelper(c, relayInfo, tokens, meta)
if err != nil {
newAPIError = types.NewError(err, types.ErrorCodeModelPriceError, types.ErrOptionWithStatusCode(http.StatusBadRequest))
return
}
// common.SetContextKey(c, constant.ContextKeyTokenCountMeta, meta)
if priceData.FreeModel {
logger.LogInfo(c, fmt.Sprintf("模型 %s 免费,跳过预扣费", relayInfo.OriginModelName))
} else {
newAPIError = service.PreConsumeBilling(c, priceData.QuotaToPreConsume, relayInfo)
if newAPIError != nil {
return
}
}
defer func() {
// Only return quota if downstream failed and quota was actually pre-consumed
if newAPIError != nil {
newAPIError = service.NormalizeViolationFeeError(newAPIError)
if relayInfo.Billing != nil {
relayInfo.Billing.Refund(c)
}
service.ChargeViolationFeeIfNeeded(c, relayInfo, newAPIError)
recovered := recover()
resultErr := newAPIError
if recovered != nil {
resultErr = types.NewError(fmt.Errorf("relay panic: %v", recovered), types.ErrorCodeBadResponse)
}
if relayFormat != types.RelayFormatOpenAIRealtime {
perfmetrics.RecordRelayResult(c.Request.Context(), relayInfo, resultErr)
}
if recovered != nil {
panic(recovered)
}
}()
if newAPIError = relay.PrepareRequestBilling(c, relayInfo); newAPIError != nil {
return
}
defer func() {
newAPIError = relay.RefundFailedRequestBilling(c, relayInfo, newAPIError)
}()
retryParam := &service.RetryParam{
@@ -220,8 +193,30 @@ func Relay(c *gin.Context, relayFormat types.RelayFormat) {
}
relayInfo.RetryIndex = 0
relayInfo.LastError = nil
maxRetries := common.RetryTimes
ordinaryRetries := 0
if relayInfo.DurableAttemptBilling {
maxRetries = 2
timeout := constant.StreamingTimeout
if timeout <= 0 {
timeout = 300
}
relayInfo.GuardDeadline = time.Now().Add(time.Duration(timeout) * time.Second)
defer func() { service.EndFailedRelayAttempt(c, relayInfo) }()
}
for ; retryParam.GetRetry() <= common.RetryTimes; retryParam.IncreaseRetry() {
for ; retryParam.GetRetry() <= maxRetries; retryParam.IncreaseRetry() {
if relayInfo.DurableAttemptBilling && time.Now().After(relayInfo.GuardDeadline) {
newAPIError = types.NewErrorWithStatusCode(errors.New("response model confirmation budget exhausted"), relaycommon.ResponseGuardErrorCode, http.StatusGatewayTimeout, types.ErrOptionWithSkipRetry())
break
}
if relayInfo.ResponseGuard != nil && relayInfo.UpstreamRequests >= relaycommon.ResponseGuardMaxAttempts {
newAPIError = types.NewErrorWithStatusCode(errors.New("response model attempt budget exhausted"), relaycommon.ResponseGuardErrorCode, http.StatusBadGateway, types.ErrOptionWithSkipRetry())
break
}
relayInfo.StreamStatus = nil
relayInfo.PerformanceBusinessRejection = false
relayInfo.PerformanceOutputTokens = 0
relayInfo.RetryIndex = retryParam.GetRetry()
channel, channelErr := getChannel(c, relayInfo, retryParam)
if channelErr != nil {
@@ -229,11 +224,15 @@ func Relay(c *gin.Context, relayFormat types.RelayFormat) {
newAPIError = channelErr
break
}
addUsedChannel(c, channel.Id)
service.AppendUsedChannel(c, channel.Id)
if billingErr := service.PrepareTieredBillingForSelectedGroup(c, relayInfo); billingErr != nil {
newAPIError = billingErr
break
}
if billingErr := service.BeginRelayAttempt(c, relayInfo); billingErr != nil {
newAPIError = billingErr
break
}
bodyStorage, bodyErr := common.GetBodyStorage(c)
if bodyErr != nil {
@@ -259,19 +258,40 @@ func Relay(c *gin.Context, relayFormat types.RelayFormat) {
}
if newAPIError == nil {
service.MarkRequestPolicySuccess(c, relayInfo.StreamStatus)
relayInfo.LastError = nil
service.RecordGroupOutcome(currentRequestGroup(c, channel), true)
return
}
upstreamFailure = true
if isGroupAvailabilityFailure(newAPIError) {
service.RecordGroupOutcome(currentRequestGroup(c, channel), false)
}
newAPIError = service.NormalizeViolationFeeError(newAPIError)
relayInfo.LastError = newAPIError
service.EndFailedRelayAttempt(c, relayInfo)
remaining := common.RetryTimes - retryParam.GetRetry()
if relayInfo.DurableAttemptBilling {
remaining = common.RetryTimes - ordinaryRetries
}
decision := service.DecideRelayRetry(c, newAPIError, remaining)
if canSwitchRejectedAstra(c, relayInfo, newAPIError) {
decision = service.PolicyDecision{Action: "retry", Reason: "model_mismatch_switch", Source: "model_guard"}
service.ClearPoolAffinity(c)
} else if decision.Action == "retry" {
ordinaryRetries++
}
service.RecordPolicyFailure(c, channel.Id, newAPIError, decision)
processChannelError(c, *types.NewChannelError(channel.Id, channel.Type, channel.Name, channel.ChannelInfo.IsMultiKey, common.GetContextKeyString(c, constant.ContextKeyChannelKey), channel.GetAutoBan()), newAPIError, relayInfo)
if !shouldRetry(c, newAPIError, common.RetryTimes-retryParam.GetRetry()) {
if decision.Action != "retry" {
break
}
if service.RequestRoutingPolicy(c) == nil {
service.MarkRequestGroupFailed(c, currentRequestGroup(c, channel))
}
}
useChannel := c.GetStringSlice("use_channel")
@@ -279,11 +299,24 @@ func Relay(c *gin.Context, relayFormat types.RelayFormat) {
retryLogStr := fmt.Sprintf("重试:%s", strings.Trim(strings.Join(strings.Fields(fmt.Sprint(useChannel)), "->"), "[]"))
logger.LogInfo(c, retryLogStr)
}
if newAPIError != nil {
gopool.Go(func() {
perfmetrics.RecordRelaySample(relayInfo, false, 0)
})
}
func canSwitchRejectedAstra(c *gin.Context, info *relaycommon.RelayInfo, err *types.NewAPIError) bool {
if !info.AstraAutoSwitch || info.ResponseGuard == nil {
return false
}
if !info.DurableAttemptBilling || info.UpstreamRequests >= 3 || err.GetErrorCode() != relaycommon.ResponseGuardErrorCode || c.Writer.Written() || c.Request.Context().Err() != nil || service.RequestRoutingPolicy(c) == nil {
return false
}
if _, pinned, _ := service.GetChannelConstraints(c).ResolvedPin(); pinned {
return false
}
attempt := info.ResponseGuard.Last()
if attempt == nil || attempt.Reason != "model_mismatch" {
return false
}
request, ok := info.Request.(*dto.OpenAIResponsesRequest)
return ok && request.PreviousResponseID == "" && (len(request.Conversation) == 0 || string(request.Conversation) == "null")
}
// CountClaudeTokens implements Anthropic's token-counting utility endpoint.
@@ -319,47 +352,12 @@ func CountClaudeTokens(c *gin.Context) {
}
var upgrader = websocket.Upgrader{
Subprotocols: []string{"realtime"}, // WS 握手支持的协议,如果有使用 Sec-WebSocket-Protocol,则必须在此声明对应的 Protocol TODO add other protocol
Subprotocols: []string{"realtime", "responses"}, // WS 握手支持的协议,如果有使用 Sec-WebSocket-Protocol,则必须在此声明对应的 Protocol
CheckOrigin: func(r *http.Request) bool {
return true // 允许跨域
},
}
func addUsedChannel(c *gin.Context, channelId int) {
useChannel := c.GetStringSlice("use_channel")
useChannel = append(useChannel, fmt.Sprintf("%d", channelId))
c.Set("use_channel", useChannel)
}
func fastTokenCountMetaForPricing(request dto.Request) *types.TokenCountMeta {
if request == nil {
return &types.TokenCountMeta{}
}
meta := &types.TokenCountMeta{
TokenType: types.TokenTypeTokenizer,
}
switch r := request.(type) {
case *dto.GeneralOpenAIRequest:
maxCompletionTokens := lo.FromPtrOr(r.MaxCompletionTokens, uint(0))
maxTokens := lo.FromPtrOr(r.MaxTokens, uint(0))
if maxCompletionTokens > maxTokens {
meta.MaxTokens = int(maxCompletionTokens)
} else {
meta.MaxTokens = int(maxTokens)
}
case *dto.OpenAIResponsesRequest:
meta.MaxTokens = int(lo.FromPtrOr(r.MaxOutputTokens, uint(0)))
case *dto.ClaudeRequest:
meta.MaxTokens = int(lo.FromPtr(r.MaxTokens))
case *dto.ImageRequest:
// Pricing for image requests depends on ImagePriceRatio; safe to compute even when CountToken is disabled.
return r.GetTokenCountMeta()
default:
// Best-effort: leave CombineText empty to avoid large allocations.
}
return meta
}
func getChannel(c *gin.Context, info *relaycommon.RelayInfo, retryParam *service.RetryParam) (*model.Channel, *types.NewAPIError) {
if info.ChannelMeta == nil {
autoBan := c.GetBool("auto_ban")
@@ -367,12 +365,14 @@ func getChannel(c *gin.Context, info *relaycommon.RelayInfo, retryParam *service
if !autoBan {
autoBanInt = 0
}
return &model.Channel{
channel := &model.Channel{
Id: c.GetInt("channel_id"),
Type: c.GetInt("channel_type"),
Name: c.GetString("channel_name"),
AutoBan: &autoBanInt,
}, nil
}
service.RequestPolicy(c).BeginAttempt(channel, info.UsingGroup)
return channel, nil
}
channel, selectGroup, err := service.CacheGetRandomSatisfiedChannel(retryParam)
if err != nil {
@@ -384,6 +384,7 @@ func getChannel(c *gin.Context, info *relaycommon.RelayInfo, retryParam *service
info.PriceData.GroupRatioInfo = helper.HandleGroupRatio(c, info)
service.RequestPolicy(c).BeginAttempt(channel, selectGroup)
newAPIError := middleware.SetupContextForSelectedChannel(c, channel, info.OriginModelName)
if newAPIError != nil {
return nil, newAPIError
@@ -391,76 +392,45 @@ func getChannel(c *gin.Context, info *relaycommon.RelayInfo, retryParam *service
return channel, nil
}
func shouldRetry(c *gin.Context, openaiErr *types.NewAPIError, retryTimes int) bool {
if openaiErr == nil {
// currentRequestGroup returns the group that handled the current attempt,
// falling back to the channel's own group when the selection did not record one.
func currentRequestGroup(c *gin.Context, channel *model.Channel) string {
if group := common.GetContextKeyString(c, constant.ContextKeyAutoGroup); group != "" {
return group
}
if channel != nil {
return channel.Group
}
return ""
}
// isGroupAvailabilityFailure reports whether an error says something about the
// selected group's health rather than about the client request itself.
func isGroupAvailabilityFailure(err *types.NewAPIError) bool {
if err == nil {
return false
}
if service.ShouldSkipRetryAfterChannelAffinityFailure(c) {
return false
}
if types.IsChannelError(openaiErr) {
if types.IsChannelError(err) {
return true
}
if types.IsSkipRetryError(openaiErr) {
return false
}
if retryTimes <= 0 {
return false
}
if service.GetChannelConstraints(c).SuppressesRetry() {
return false
}
code := openaiErr.StatusCode
if code >= 200 && code < 300 {
return false
}
if code < 100 || code > 599 {
return true
}
if operation_setting.IsAlwaysSkipRetryCode(openaiErr.GetErrorCode()) {
return false
}
return operation_setting.ShouldRetryByStatusCode(code)
return err.StatusCode >= 500
}
func shouldRetry(c *gin.Context, err *types.NewAPIError, retryTimes int) bool {
return service.ShouldRetryRelayError(c, err, retryTimes)
}
func processChannelError(c *gin.Context, channelError types.ChannelError, err *types.NewAPIError, relayInfo *relaycommon.RelayInfo) {
logger.LogError(c, fmt.Sprintf("channel error (channel #%d, status code: %d): %s", channelError.ChannelId, err.StatusCode, common.LocalLogPreview(err.Error())))
// 不要使用context获取渠道信息,异步处理时可能会出现渠道信息不一致的情况
// do not use context to get channel info, there may be inconsistent channel info when processing asynchronously
if service.ShouldDisableChannel(err) && channelError.AutoBan {
gopool.Go(func() {
service.DisableChannel(channelError, err.ErrorWithStatusCode())
})
}
if constant.ErrorLogEnabled && types.IsRecordErrorLog(err) {
// 保存错误日志到mysql中
userId := c.GetInt("id")
tokenName := c.GetString("token_name")
modelName := c.GetString("original_model")
tokenId := c.GetInt("token_id")
userGroup := c.GetString("group")
other := model.NewLogOther()
if c.Request != nil && c.Request.URL != nil {
other.SetPublic("request_path", c.Request.URL.Path)
}
other.SetPublic("error_type", err.GetErrorType())
other.SetPublic("error_code", err.GetErrorCode())
other.SetPublic("status_code", err.StatusCode)
service.AppendRelayLogAdminInfo(c, relayInfo, other)
service.AppendTaskPluginContextAuditInfo(c, other)
startTime := common.GetContextKeyTime(c, constant.ContextKeyRequestStartTime)
if startTime.IsZero() {
startTime = time.Now()
}
useTimeSeconds := int(time.Since(startTime).Seconds())
other.SetRoot("upstream_error", err.MaskSensitiveErrorWithStatusCode())
model.RecordErrorLog(c, userId, channelError.ChannelId, modelName, tokenName, publicUpstreamErrorContent(err.StatusCode), tokenId, useTimeSeconds, common.GetContextKeyBool(c, constant.ContextKeyIsStream), userGroup, other)
}
service.ProcessChannelError(c, channelError, err, relayInfo)
}
func RelayMidjourney(c *gin.Context) {
policy := service.RequestPolicy(c)
defer func() {
if policy.Attempts > 0 && !policy.Successful {
service.RecordRequestPolicyTermination(c, types.NewErrorWithStatusCode(errors.New("Midjourney submission failed"), types.ErrorCodeBadResponseStatusCode, http.StatusBadGateway, types.ErrOptionWithSkipRetry()))
}
}()
relayInfo, err := relaycommon.GenRelayInfo(c, types.RelayFormatMjProxy, nil, nil)
if err != nil {
@@ -488,6 +458,7 @@ func RelayMidjourney(c *gin.Context) {
//err = relayMidjourneySubmit(c, relayMode)
log.Println(mjErr)
if mjErr != nil {
policy.Successful = false
statusCode := http.StatusBadRequest
if mjErr.Code == 30 {
mjErr.Result = "当前分组负载已饱和,请稍后再试,或升级账户以提升服务质量。"
@@ -627,6 +598,7 @@ func executeTaskSubmissionWith(
relayInfo *relaycommon.RelayInfo,
submit taskSubmitAttempt,
) (*taskSubmissionOutcome, *taskdto.TaskError) {
policy := service.RequestPolicy(c)
diagnostics := newTaskPluginSubmitDiagnostics(c)
diagnostics.start(relayInfo)
var result *relay.TaskSubmitResult
@@ -664,6 +636,7 @@ func executeTaskSubmissionWith(
if lockedCh, ok := relayInfo.LockedChannel.(*model.Channel); ok && lockedCh != nil {
channel = lockedCh
policy.BeginAttempt(channel, relayInfo.UsingGroup)
if retryParam.GetRetry() > 0 {
if setupErr := middleware.SetupContextForSelectedChannel(c, channel, relayInfo.OriginModelName); setupErr != nil {
taskErr = service.TaskErrorWrapperLocal(setupErr.Err, "setup_locked_channel_failed", http.StatusInternalServerError)
@@ -675,13 +648,13 @@ func executeTaskSubmissionWith(
channel, channelErr = getChannel(c, relayInfo, retryParam)
if channelErr != nil {
logger.LogError(c, channelErr.Error())
taskErr = service.TaskErrorWrapperLocal(channelErr.Err, "get_channel_failed", http.StatusInternalServerError)
taskErr = service.TaskErrorWrapperLocal(channelErr.Err, "get_channel_failed", channelErr.StatusCode)
break
}
}
diagnostics.attempt(retryParam.GetRetry()+1, channel, relayInfo.LockedChannel != nil)
addUsedChannel(c, channel.Id)
service.AppendUsedChannel(c, channel.Id)
bodyStorage, bodyErr := common.GetBodyStorage(c)
if bodyErr != nil {
stage = "read_body"
@@ -706,15 +679,19 @@ func executeTaskSubmissionWith(
break
}
taskAPIError := taskSubmissionAPIError(taskErr)
relayInfo.LastError = taskAPIError
decision := decideTaskRetry(c, taskErr, common.RetryTimes-retryParam.GetRetry())
service.RecordPolicyFailure(c, channel.Id, taskAPIError, decision)
if !taskErr.LocalError {
processChannelError(c,
*types.NewChannelError(channel.Id, channel.Type, channel.Name, channel.ChannelInfo.IsMultiKey,
common.GetContextKeyString(c, constant.ContextKeyChannelKey), channel.GetAutoBan()),
types.NewOpenAIError(taskErr.Error, types.ErrorCodeBadResponseStatusCode, taskErr.StatusCode),
taskAPIError,
relayInfo)
}
willRetry := shouldRetryTaskRelay(c, channel.Id, taskErr, common.RetryTimes-retryParam.GetRetry())
willRetry := decision.Action == "retry"
diagnostics.attemptFailed(retryParam.GetRetry()+1, channel, taskErr, willRetry)
if !willRetry {
break
@@ -816,6 +793,11 @@ func executeTaskSubmissionWith(
diagnostics.failed("settle", "billing_error", taskErr, true)
return nil, taskErr
}
if task.Status != model.TaskStatusFailure {
service.MarkRequestPolicySuccess(c, nil)
} else {
policy.AddEvent(service.PolicyEvent{Decision: service.PolicyDecision{Action: "stop", Reason: "task_failed", Source: "upstream"}})
}
service.LogTaskConsumption(c, relayInfo, task)
diagnostics.complete(task, result.Quota)
@@ -876,6 +858,7 @@ func presentTaskSubmission(c *gin.Context, outcome *taskSubmissionOutcome) {
}
func respondTaskSubmissionError(c *gin.Context, taskErr *taskdto.TaskError) {
service.RecordRequestPolicyTermination(c, taskSubmissionAPIError(taskErr))
newTaskPluginSubmitDiagnostics(c).presentError(taskErr)
if middleware.RespondTaskPluginError(c, taskErr) {
return
@@ -891,44 +874,53 @@ func respondTaskError(c *gin.Context, taskErr *taskdto.TaskError) {
c.JSON(taskErr.StatusCode, taskErr)
}
func shouldRetryTaskRelay(c *gin.Context, channelId int, taskErr *taskdto.TaskError, retryTimes int) bool {
if taskErr == nil {
return false
// taskSubmissionAPIError adapts a task error for the shared relay error paths.
// TaskError.Error is nil for many local rejections, so fall back to the message.
func taskSubmissionAPIError(taskErr *taskdto.TaskError) *types.NewAPIError {
err := taskErr.Error
if err == nil {
err = errors.New(taskErr.Message)
}
if service.ShouldSkipRetryAfterChannelAffinityFailure(c) {
return false
}
if retryTimes <= 0 {
return false
}
if service.GetChannelConstraints(c).SuppressesRetry() {
return false
}
if taskErr.StatusCode == http.StatusTooManyRequests {
return true
}
if taskErr.StatusCode == 307 {
return true
}
if taskErr.StatusCode/100 == 5 {
return types.NewOpenAIError(err, types.ErrorCodeBadResponseStatusCode, taskErr.StatusCode)
}
// decideTaskRetry is the single retry decision for task submissions. The
// reason is recorded in the request policy decision events of the log details.
func decideTaskRetry(c *gin.Context, taskErr *taskdto.TaskError, retryTimes int) service.PolicyDecision {
stop := service.PolicyDecision{Action: "stop", Source: "system"}
retry := service.PolicyDecision{Action: "retry", Reason: "retry_status_matched", Source: "system"}
switch {
case taskErr == nil:
stop.Reason = "request_completed"
case taskErr.NoRetry:
stop.Reason = "task_accepted"
case service.ShouldSkipRetryAfterChannelAffinityFailure(c):
stop.Reason, stop.Source = "strict_session", "session_rule"
if source := service.RequestPolicy(c).SessionModeSource; source != "" {
stop.Source = source
}
case retryTimes <= 0:
stop.Reason, stop.Source = "attempt_budget_exhausted", "global"
case service.GetChannelConstraints(c).SuppressesRetry():
stop.Reason, stop.Source = "pinned_channel", "channel_constraint"
case taskErr.StatusCode == http.StatusTooManyRequests, taskErr.StatusCode == 307:
return retry
case taskErr.StatusCode/100 == 5:
// 超时不重试
if operation_setting.IsAlwaysSkipRetryStatusCode(taskErr.StatusCode) {
return false
stop.Reason = "system_retry_exclusion"
break
}
return true
}
if taskErr.StatusCode == http.StatusBadRequest {
return false
}
if taskErr.StatusCode == 408 {
return retry
case taskErr.StatusCode == http.StatusBadRequest, taskErr.StatusCode == 408:
// azure处理超时不重试
return false
stop.Reason = "status_not_retryable"
case taskErr.LocalError:
stop.Reason = "local_rejection"
case taskErr.StatusCode/100 == 2:
stop.Reason = "system_retry_exclusion"
default:
return retry
}
if taskErr.LocalError {
return false
}
if taskErr.StatusCode/100 == 2 {
return false
}
return true
return stop
}