Skip to content

feat(relay): exclude-driven channel failover, adaptive retry budget, and rate-limit cooldown circuit breaker - #6580

Open
Luckylos wants to merge 7 commits into
QuantumNous:mainfrom
Luckylos:pr-channel-failover-retry
Open

feat(relay): exclude-driven channel failover, adaptive retry budget, and rate-limit cooldown circuit breaker#6580
Luckylos wants to merge 7 commits into
QuantumNous:mainfrom
Luckylos:pr-channel-failover-retry

Conversation

@Luckylos

@Luckylos Luckylos commented Aug 1, 2026

Copy link
Copy Markdown

Summary

Improves relay reliability: exclude-driven channel failover, adaptive retry budget, and a rate-limit cooldown circuit breaker that respects upstream Retry-After hints. Also fixes a data-corruption bug where a retry could append a second response after SSE bytes were already flushed to the client.

Fixes the long-standing "no available channel" failures where healthy channels were never tried before the request failed (see #852 — automatic disable of slow channels), plus several related root causes.

Changes

1. Exclude-driven channel failover + adaptive retry

  • Retry no longer re-selects already-failed channels: GetRandomSatisfiedChannel/GetChannel take an excludeChannels set; selection is rewritten to be exclude-driven (group non-excluded candidates by priority, pick the highest tier that still has channels, weighted-random within it). Returns (nil,nil) when the pool is exhausted so the caller stops cleanly.
  • Same-priority channels are all exhausted before descending (previously a priority index skipped same-priority siblings).
  • 504/524 timeouts now fail over instead of aborting: removed the hardcoded always-skip guard from the sync relay path; AutomaticRetryStatusCodeRanges is authoritative (defaults to full 5xx).
  • Adaptive retry budget: cap = max(RetryTimes, availableChannels-1) so every channel can be tried even when RetryTimes is smaller than the pool. Pinned-channel requests stay at 1.

2. Failover tech-debt paydown (multi-key aware)

  • Removed dead retry param, priorityRetry index, retry-counter manipulation, resetNextTry, dead AutoGroupRetryIndex writes.
  • Auto-group selection is now purely exclude-driven; distinguishes discovery miss (always skip group) from failover exhaustion (advance only when cross-group retry enabled).
  • Multi-key aware exclude: a channel is only excluded after all its enabled keys have been tried, preserving per-request key rotation (GetNextEnabledKey).
  • Retry budget sized by total enabled keys (CountEnabledKeys), so every key of a multi-key channel fits within the adaptive cap.
  • Fixed shouldRetry to use retryCap instead of common.RetryTimes (adaptive cap was silently clamped).

3. Rate-limit cooldown circuit breaker

  • Cross-request key-level cooldown store (TTL map, clamped to 300s).
  • Parses upstream Retry-After / X-RateLimit-Reset headers, including OpenAI Go-duration formats ("6m0s", "1s", "88ms"); previously ParseFloat failed on these and the precise upstream hint was silently dropped to the default cooldown. Sub-second resets round up to 1s.
  • Cooldown registered on 429 (or any Retry-After), key-aware; channel selection prefers non-cooling keys/channels with fallback, so cooldown never denies service.
  • Cooldown cleared on success (sync + task relay loops).
  • Cooldown marking decoupled from the health-check path (processChannelError), so admin channel tests never pollute live rate-limit state.

4. Channel-level failure excludes the whole channel

  • Channel-level upstream failures (5xx, auth_unavailable — anything that is not a per-key 429/Retry-After) hit every key of a channel the same way; burning the remaining keys only adds latency before failover.
  • NewAPIError.IsRateLimited introduced as the single source of truth (shared with cooldown); recordChannelFailure excludes the channel immediately on channel-level error, while rate-limits still rotate keys until all are throttled. Applied to both sync relay and async task loops.

5. Fix: never retry after response bytes sent to client

  • shouldRetry now bails out when relayInfo.HasSendResponse() is true. A mid-stream upstream error (e.g. Claude emitting an error event after message_start has already flushed SSE bytes) could previously trigger a retry that appended a second response onto the partial output on the same http.ResponseWriter, corrupting/duplicating what the client saw.

Testing

  • New tests (all pass): channel_failover_test.go, channel_cooldown_test.go, record_channel_failure_test.go, should_retry_test.go, retry_after_test.go, updated status_code_ranges_test.go.
  • go build, go vet, and go test ./controller/... ./model/... ./service/... ./setting/... all pass on top of current upstream main.
  • No DB schema changes (relay-only).

Summary by CodeRabbit

  • New Features
    • Improved request failover by excluding failed channels and selecting alternate available channels.
    • Added rate-limit cooldown handling, including support for upstream retry hints.
    • Enhanced multi-key channel selection to avoid temporarily limited keys.
  • Bug Fixes
    • Automatic retries now cover all 5xx responses, including 504 and 524.
    • Retries stop once response data has begun streaming.
    • Improved retry behavior when channels or keys are exhausted.
  • Release
    • Version v1.0.0-rc.21-erdev.2 added.

Luckylos Dev added 7 commits August 1, 2026 10:59
Fixes "no available channel" errors where healthy channels were never
tried before the request failed. Three root causes addressed:

1. Retry no longer re-selects failed channels (QuantumNous#1)
   GetRandomSatisfiedChannel / GetChannel now take an excludeChannels
   set (channel IDs already tried this request). Selection is rewritten
   to be exclude-driven: group non-excluded candidates by priority, pick
   the highest tier that still has channels, weighted-random within it.
   Returns (nil,nil) when the pool is exhausted so the caller stops
   cleanly. The relay loop populates the set after each attempt.

2. Same-priority channels are all exhausted before descending (QuantumNous#6)
   Previously retry was a priority index, so same-priority siblings
   were skipped. Now every channel in a tier is tried first.

3. 504/524 timeouts fail over instead of aborting (QuantumNous#2)
   Removed the hardcoded always-skip guard from the sync relay path;
   AutomaticRetryStatusCodeRanges is now authoritative and defaults to
   the full 5xx range. The async task relay keeps its own timeout
   protection to avoid duplicate submits.

4. Adaptive retry budget (QuantumNous#5)
   Retry cap = max(RetryTimes, availableChannels-1) via new
   CountAvailableChannels, so every channel can be tried even when
   RetryTimes is smaller than the pool. Pinned-channel requests stay at 1.

Tests: channel_failover_test.go + updated status_code_ranges_test.go.
go build + vet + all package tests pass.
…e, pure exclude-driven auto group, adaptive cap fixes

- Remove dead `retry` param from GetRandomSatisfiedChannel/GetChannel (debt QuantumNous#1)
- Rewrite auto-group selection to be purely exclude-driven; drop priorityRetry
  index, retry-counter manipulation, resetNextTry, and dead AutoGroupRetryIndex
  writes. Distinguish discovery miss (always skip group) from failover
  exhaustion (advance only when cross-group retry enabled) (debt QuantumNous#2)
- Multi-key aware exclude: a channel is only excluded after all its enabled
  keys have been tried, preserving per-request key rotation (debt QuantumNous#3)
- Size retry budget by total enabled keys (CountEnabledKeys) so every key of a
  multi-key channel fits within the adaptive cap (debt QuantumNous#3)
- Fix shouldRetry to use retryCap instead of common.RetryTimes so the adaptive
  cap is not silently clamped (debt QuantumNous#4)
- Task relay loop now carries exclude set with the same multi-key logic
- Tests: multi-key CountEnabledKeys + key-weighted budget
…ry-After, skip cooling channels/keys in selection with fallback

- Add cross-request key-level cooldown store (TTL map, clamped to 300s)
- Parse upstream Retry-After / X-RateLimit-Reset headers in RelayErrorHandler
- Register cooldown on 429 (or any Retry-After) in processChannelError, key-aware
- GetNextEnabledKey and channel selection prefer non-cooling keys/channels, always fall back so cooldown never denies service
- Clear cooldown on success (sync + task relay loops)
- TDD: cooldown store, selection skip, key skip, Retry-After parsing
… health-check path, parse OpenAI duration reset headers

- Move cooldown mark out of processChannelError (shared with channel
  health-check path) into the relay loops via markCooldownFromError, so
  admin channel tests never pollute live rate-limit state.
- Extract contextKeyIndex / clearCooldownForContext helpers, removing
  three copies of the multi-key-index lookup.
- Parse OpenAI's Go-duration reset headers ("6m0s", "1s", "88ms") in
  ParseRetryAfterSeconds; previously ParseFloat failed on them and the
  precise upstream hint was silently dropped to the default cooldown.
  Sub-second resets round up to 1s. Adds regression tests.
shouldRetry now bails out when relayInfo.HasSendResponse() is true, so a
mid-stream upstream error (e.g. Claude emitting an error event after
message_start has already flushed SSE bytes) can no longer trigger a
retry that appends a second response onto the partial output on the same
http.ResponseWriter, which corrupted/duplicated what the client saw.

Individual stream handlers mostly avoid propagating a retryable error
after their first flush, but that protection was scattered and had gaps
(Claude's WithClaudeError path returns a retryable 500 that is not in
alwaysSkipRetryCodes). This adds the single authoritative guard.
…ile key rotation

Channel-level upstream failures (5xx, auth_unavailable — anything that is not
a per-key 429/Retry-After) hit every key of a channel the same way, so burning
the remaining keys only adds latency before failover. Introduce
NewAPIError.IsRateLimited as the single source of truth (shared with cooldown)
and a recordChannelFailure helper that excludes the channel immediately on a
channel-level error, while a rate-limit still rotates keys until all are
throttled. Applied to both the sync relay loop and the async task loop.
upstream extracted relaykit (QuantumNous#6369), moving NewAPIError/ErrorCode to
relaykit/types and AdvancedCustomConfig to relaykit/dto. Update the
test imports to match; behavior unchanged.
@coderabbitai

coderabbitai Bot commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

Relay retries now exclude failed channels, rotate keys for rate-limited channels, apply parsed cooldowns, and adapt retry limits to available keys. Channel selection and group routing accept exclusions. Synchronous retries include the full 5xx range. The version is updated.

Changes

Adaptive channel failover

Layer / File(s) Summary
Rate-limit hint handling
relaykit/types/error.go, service/error.go, service/retry_after_test.go
Errors now retain upstream retry hints. Header parsing supports delta values, dates, timestamps, durations, and precedence rules.
Cooldown-aware channel selection
model/channel_cooldown.go, model/channel.go, model/ability.go, model/channel_cache.go, model/*_test.go
Channel keys use bounded cooldowns. Selection skips cooling keys when ready keys exist, filters excluded channels, preserves priority tiers, and counts enabled keys for retry budgets.
Retry state and group routing
service/channel_select.go
Retry state now stores excluded channel IDs. Auto-group and non-auto selection pass exclusions and update group failover state.
Relay failure processing and retry budgets
controller/relay.go, controller/record_channel_failure_test.go, controller/should_retry_test.go
Relay and task retries record channel failures, rotate rate-limited keys, clear successful cooldowns, size retry budgets from available keys, and stop after response output begins.
Retry policy and release metadata
setting/operation_setting/status_code_ranges.go, setting/operation_setting/status_code_ranges_test.go, VERSION
The default synchronous retry range now covers all 5xx responses, including 504 and 524. The version is set to v1.0.0-rc.21-erdev.2.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Relay
  participant ChannelSelection
  participant Upstream
  participant RetryState
  Relay->>ChannelSelection: request channel with exclusions
  ChannelSelection-->>Relay: return channel and key
  Relay->>Upstream: send request
  Upstream-->>Relay: return response or failure
  Relay->>RetryState: clear cooldown or record failure
  RetryState-->>Relay: provide retry decision
Loading

Possibly related PRs

Suggested reviewers: calcium-ion

Poem

A rabbit marks the failed path,
And cools each key after wrath.
Ready channels hop in line,
Five-oh-four now gets retry time.
New release bells softly chime.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 51.43% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the PR's main changes: channel failover, adaptive retry budgeting, and rate-limit cooldown handling.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 6

🧹 Nitpick comments (2)
model/ability.go (1)

109-120: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Consider applying key cooldown in the cache-disabled path too.

model/channel_cache.go Lines 181-189 skip channels whose enabled keys are all in cooldown. This candidate list has no equivalent step, so deployments with MemoryCacheEnabled=false keep selecting a just-throttled channel. Load the candidate channels once and apply EnabledKeysAllCoolingDown with the same full-tier fallback to keep behavior identical in both modes.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@model/ability.go` around lines 109 - 120, The candidate selection loop in the
ability selection flow must also exclude channels whose enabled keys are all
cooling down when the cache is disabled. Load candidate channels once, apply
EnabledKeysAllCoolingDown before appending candidates, and preserve the same
full-tier fallback behavior used by model/channel_cache.go so both cache modes
select consistently.
controller/relay.go (1)

384-396: 🩺 Stability & Availability | 🔵 Trivial | 💤 Low value

Do not use HasSendResponse() to mean client writes.

Several assignment sites mark the response as sent after upstream reads/events and before rendering:

  • relay/helper/stream_scanner.go: SetFirstResponseTime() runs before dataChan.
  • relay/channel/openai/relay_realtime.go: SetFirstResponseTime() runs before JSON unmarshalling.
  • relay/channel/aws/relay-aws.go: SetFirstResponseTime() runs before HandleStreamResponseData.
  • relay/channel/openai/relay_image.go: SetFirstResponseTime() runs before marshalling usage and SSE payloads.

Only some direct render sites are close to writes, such as relay/channel/cloudflare/relay_cloudflare.go and relay/channel/cohere/relay-cohere.go. Update the shouldRetry comment to say that this guard checks whether the relay session has begun reporting response timing, or move the write guard to actual Writer/render state for each channel.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@controller/relay.go` around lines 384 - 396, Update the comment in
shouldRetry to accurately describe info.HasSendResponse() as indicating that the
relay session has begun reporting response timing, not that bytes were written
to the client; leave the retry guard behavior unchanged unless an existing
writer/render-state signal is already available.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@model/ability.go`:
- Around line 69-83: The GetChannel function currently filters only abilities
loaded for the exact model, causing discovery to miss channels that match the
normalized model name. Before filterAbilitiesByRequestPathAndModel, apply the
same ratio_setting.FormatMatchingModelName(model) fallback used by
model/channel_cache.go when exact-model abilities produce no matches, and
preserve the existing ordering and empty-result behavior.

In `@model/channel_cache.go`:
- Around line 268-296: Update CountAvailableChannels in the cache-disabled
branch to replace per-ability GetChannelById calls with one batched channel
query using the distinct ChannelId values, selecting only fields needed to count
enabled keys rather than full key material. Preserve the existing fallback count
for missing channel records, deduplication, request-path filtering, and
normalized-model retry behavior.

In `@model/channel_cooldown_test.go`:
- Around line 11-174: Replace raw testing assertions in
model/channel_cooldown_test.go lines 11-174 with testify/require equivalents for
fatal checks, using require.True, require.False, require.Equal, require.NotNil,
or require.NoError as appropriate; use assert only for non-fatal checks, and add
the required import. In service/retry_after_test.go lines 10-116, replace every
t.Fatalf with testify/require assertions comparing expected values from
ParseRetryAfterSeconds, and add the required import.

In `@model/channel_failover_test.go`:
- Around line 50-210: Update model/channel_failover_test.go lines 50-210 to use
testify/require for NoError, Equal, Nil, and NotNil assertions in the channel
failover tests. Update controller/record_channel_failure_test.go lines 27-86 to
use require.True/False for exclusion checks and assert.Equal for counter checks.
Update controller/should_retry_test.go lines 45-73 to use require.False/True for
retry expectations and explicitly initialize retry status-code settings and
relevant test state instead of relying on global defaults.
- Around line 13-48: Update setupChannelCache to reset the channelKeyCooldown
global when initializing the fixture and restore its previous value in the
returned cleanup function, alongside the other cache globals, so tests reusing
channel IDs remain isolated and deterministic.

In `@relaykit/types/error.go`:
- Around line 127-144: Update NewAPIError.IsRateLimited to return true only when
StatusCode equals http.StatusTooManyRequests; remove the RetryAfterSeconds
condition so retry hints on 5xx or other responses cannot classify the error as
key-level rate limiting.

---

Nitpick comments:
In `@controller/relay.go`:
- Around line 384-396: Update the comment in shouldRetry to accurately describe
info.HasSendResponse() as indicating that the relay session has begun reporting
response timing, not that bytes were written to the client; leave the retry
guard behavior unchanged unless an existing writer/render-state signal is
already available.

In `@model/ability.go`:
- Around line 109-120: The candidate selection loop in the ability selection
flow must also exclude channels whose enabled keys are all cooling down when the
cache is disabled. Load candidate channels once, apply EnabledKeysAllCoolingDown
before appending candidates, and preserve the same full-tier fallback behavior
used by model/channel_cache.go so both cache modes select consistently.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 75bd366c-3006-4518-b96c-25b9d9579d51

📥 Commits

Reviewing files that changed from the base of the PR and between cfaba1d and 8344916.

📒 Files selected for processing (16)
  • VERSION
  • controller/record_channel_failure_test.go
  • controller/relay.go
  • controller/should_retry_test.go
  • model/ability.go
  • model/channel.go
  • model/channel_cache.go
  • model/channel_cooldown.go
  • model/channel_cooldown_test.go
  • model/channel_failover_test.go
  • relaykit/types/error.go
  • service/channel_select.go
  • service/error.go
  • service/retry_after_test.go
  • setting/operation_setting/status_code_ranges.go
  • setting/operation_setting/status_code_ranges_test.go

Comment thread model/ability.go
Comment on lines +69 to 83
func GetChannel(group string, model string, requestPath string, excludeChannels map[int]bool) (*Channel, error) {
var abilities []Ability

// Load all enabled abilities for this group/model ordered by priority so we
// can pick the highest tier that still has non-excluded channels.
err := DB.Where(commonGroupCol+" = ? and model = ? and enabled = ?", group, model, true).
Order("priority DESC").
Find(&abilities).Error
if err != nil {
// 处理错误
return 0, err
}

if len(priorities) == 0 {
// 如果没有查询到优先级,则返回错误
return 0, errors.New("数据库一致性被破坏")
return nil, err
}

// 确定要使用的优先级
var priorityToUse int
if retry >= len(priorities) {
// 如果重试次数大于优先级数,则使用最小的优先级
priorityToUse = priorities[len(priorities)-1]
} else {
priorityToUse = priorities[retry]
abilities = filterAbilitiesByRequestPathAndModel(abilities, requestPath, model)
if len(abilities) == 0 {
return nil, nil
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Verify model-name resolution parity between the DB path and the cache path.
set -uo pipefail

# Where is FormatMatchingModelName used in model/?
rg -n --type=go 'FormatMatchingModelName' model/

# Inspect the ability query and any normalized retry in ability.go
ast-grep run --pattern 'func GetChannel($$$) { $$$ }' --lang go model/ability.go

Repository: QuantumNous/new-api

Length of output: 616


🏁 Script executed:

#!/bin/bash
set -uo pipefail

echo "== Locate ability.go and relevant files =="
fd -a 'ability\.go|channel_cache\.go|channel_select\.go|channel_satisfy\.go|.*ratio.*' . | sed 's#^\./##'

echo
echo "== model/ability.go outline/section =="
wc -l model/ability.go
sed -n '1,140p' model/ability.go | cat -n

echo
echo "== model/channel_cache.go relevant sections =="
wc -l model/channel_cache.go
sed -n '110,180p' model/channel_cache.go | cat -n
sed -n '260,315p' model/channel_cache.go | cat -n

echo
echo "== service/channel_select.go around hadChannels =="
wc -l service/channel_select.go
sed -n '1,180p' service/channel_select.go | cat -n

echo
echo "== model/channel_satisfy.go =="
wc -l model/channel_satisfy.go
sed -n '1,90p' model/channel_satisfy.go | cat -n

echo
echo "== FormatMatchingModelName definitions/usages =="
rg -n --type=go 'FormatMatchingModelName|type .*Ratio|OtherRatios|matching' .

Repository: QuantumNous/new-api

Length of output: 31566


🏁 Script executed:

#!/bin/bash
set -uo pipefail

echo "== model/ability.go lines 140-230 =="
sed -n '140,230p' model/ability.go | cat -n

echo
echo "== CountAvailableChannels full section with model fallback =="
sed -n '1,55p' model/channel_cache.go | cat -n

echo
echo "== FormatMatchingModelName definition =="
sed -n '700,740p' setting/ratio_setting/model_ratio.go | cat -n

Repository: QuantumNous/new-api

Length of output: 7243


Add the normalized-model fallback to GetChannel.

model/channel_cache.go retries with ratio_setting.FormatMatchingModelName(model) when no channels match the exact model. Use the same fallback in model/ability.go before filterAbilitiesByRequestPathAndModel, so CountAvailableChannels and selection exit discovery misses together and do not stop auto-group routing on a false positive count.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@model/ability.go` around lines 69 - 83, The GetChannel function currently
filters only abilities loaded for the exact model, causing discovery to miss
channels that match the normalized model name. Before
filterAbilitiesByRequestPathAndModel, apply the same
ratio_setting.FormatMatchingModelName(model) fallback used by
model/channel_cache.go when exact-model abilities produce no matches, and
preserve the existing ordering and empty-result behavior.

Comment thread model/channel_cache.go
Comment on lines +268 to +296
func CountAvailableChannels(group string, model string, requestPath string) int {
if !common.MemoryCacheEnabled {
var abilities []Ability
if err := DB.Where(commonGroupCol+" = ? and model = ? and enabled = ?", group, model, true).
Find(&abilities).Error; err != nil {
return 0
}
abilities = filterAbilitiesByRequestPathAndModel(abilities, requestPath, model)
seen := make(map[int]struct{}, len(abilities))
total := 0
for _, ability := range abilities {
if _, ok := seen[ability.ChannelId]; ok {
continue
}
seen[ability.ChannelId] = struct{}{}
if channel, err := GetChannelById(ability.ChannelId, true); err == nil {
total += channel.CountEnabledKeys()
} else {
total++
}
}
if total == 0 {
normalized := ratio_setting.FormatMatchingModelName(model)
if normalized != "" && normalized != model {
return CountAvailableChannels(group, normalized, requestPath)
}
}
return total
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚀 Performance & Scalability | 🟠 Major | ⚡ Quick win

Batch the channel lookups in the cache-disabled path.

The loop calls GetChannelById once per distinct ability, so this helper issues N+1 queries. It runs on the request path: controller/relay.go Line 346 calls it once per auto group for every request, and service/channel_select.go Line 112 calls it again per group on every retry. selectAll=true also selects the key column, so full key material is read only to count keys.

Load the candidate channels in one query instead.

♻️ Proposed fix: single batched query
 		abilities = filterAbilitiesByRequestPathAndModel(abilities, requestPath, model)
-		seen := make(map[int]struct{}, len(abilities))
-		total := 0
-		for _, ability := range abilities {
-			if _, ok := seen[ability.ChannelId]; ok {
-				continue
-			}
-			seen[ability.ChannelId] = struct{}{}
-			if channel, err := GetChannelById(ability.ChannelId, true); err == nil {
-				total += channel.CountEnabledKeys()
-			} else {
-				total++
-			}
-		}
+		seen := make(map[int]struct{}, len(abilities))
+		channelIds := make([]int, 0, len(abilities))
+		for _, ability := range abilities {
+			if _, ok := seen[ability.ChannelId]; ok {
+				continue
+			}
+			seen[ability.ChannelId] = struct{}{}
+			channelIds = append(channelIds, ability.ChannelId)
+		}
+		total := 0
+		if len(channelIds) > 0 {
+			var channels []*Channel
+			if err := DB.Where("id IN ?", channelIds).Find(&channels).Error; err != nil {
+				// fall back to one attempt per channel
+				total = len(channelIds)
+			} else {
+				found := make(map[int]struct{}, len(channels))
+				for _, channel := range channels {
+					found[channel.Id] = struct{}{}
+					total += channel.CountEnabledKeys()
+				}
+				total += len(channelIds) - len(found)
+			}
+		}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@model/channel_cache.go` around lines 268 - 296, Update CountAvailableChannels
in the cache-disabled branch to replace per-ability GetChannelById calls with
one batched channel query using the distinct ChannelId values, selecting only
fields needed to count enabled keys rather than full key material. Preserve the
existing fallback count for missing channel records, deduplication, request-path
filtering, and normalized-model retry behavior.

Comment on lines +11 to +174
func TestChannelKeyCooldown_MarkIsClear(t *testing.T) {
channelKeyCooldown.Range(func(k, _ any) bool {
channelKeyCooldown.Delete(k)
return true
})

if IsChannelKeyCoolingDown(100, 0) {
t.Fatal("fresh key should not be cooling down")
}

MarkChannelKeyCooldown(100, 0, 30)
if !IsChannelKeyCoolingDown(100, 0) {
t.Fatal("key should be cooling down after Mark")
}
// Different key index of the same channel is independent.
if IsChannelKeyCoolingDown(100, 1) {
t.Fatal("unrelated key index should not be cooling down")
}

ClearChannelKeyCooldown(100, 0)
if IsChannelKeyCoolingDown(100, 0) {
t.Fatal("key should be selectable after Clear")
}
}

func TestChannelKeyCooldown_Expiry(t *testing.T) {
// A cooldown that already expired must read as not cooling down and be
// cleaned up lazily.
channelKeyCooldown.Store(channelKeyCooldownMapKey(101, 0), time.Now().Unix()-1)
if IsChannelKeyCoolingDown(101, 0) {
t.Fatal("expired cooldown should read as not cooling down")
}
if _, ok := channelKeyCooldown.Load(channelKeyCooldownMapKey(101, 0)); ok {
t.Fatal("expired entry should be cleaned up on read")
}
}

func TestChannelKeyCooldown_Clamp(t *testing.T) {
channelKeyCooldown.Range(func(k, _ any) bool {
channelKeyCooldown.Delete(k)
return true
})

// Oversized hint is clamped so a hostile header cannot park a key forever.
MarkChannelKeyCooldown(102, 0, MaxChannelCooldownSeconds+10_000)
v, _ := channelKeyCooldown.Load(channelKeyCooldownMapKey(102, 0))
until, _ := v.(int64)
if max := time.Now().Unix() + int64(MaxChannelCooldownSeconds); until > max {
t.Fatalf("cooldown not clamped: until=%d, max=%d", until, max)
}
}

func TestEnabledKeysAllCoolingDown_SingleKey(t *testing.T) {
channelKeyCooldown.Range(func(k, _ any) bool {
channelKeyCooldown.Delete(k)
return true
})
ch := &Channel{Id: 200, Key: "k1"}

if ch.EnabledKeysAllCoolingDown() {
t.Fatal("single-key channel with no cooldown should be ready")
}
MarkChannelKeyCooldown(200, 0, 30)
if !ch.EnabledKeysAllCoolingDown() {
t.Fatal("single-key channel with its key cooling should report all-cooling")
}
}

func TestEnabledKeysAllCoolingDown_MultiKey(t *testing.T) {
channelKeyCooldown.Range(func(k, _ any) bool {
channelKeyCooldown.Delete(k)
return true
})
ch := &Channel{Id: 201, Key: "k1\nk2\nk3"}
ch.ChannelInfo.IsMultiKey = true

// One of three keys cooling -> channel still has ready keys.
MarkChannelKeyCooldown(201, 0, 30)
if ch.EnabledKeysAllCoolingDown() {
t.Fatal("channel with 2 ready keys should not be all-cooling")
}
// All three cooling -> all-cooling.
MarkChannelKeyCooldown(201, 1, 30)
MarkChannelKeyCooldown(201, 2, 30)
if !ch.EnabledKeysAllCoolingDown() {
t.Fatal("channel with every key cooling should report all-cooling")
}
}

func TestEnabledKeysAllCoolingDown_SkipsDisabledKeys(t *testing.T) {
channelKeyCooldown.Range(func(k, _ any) bool {
channelKeyCooldown.Delete(k)
return true
})
ch := &Channel{Id: 202, Key: "k1\nk2"}
ch.ChannelInfo.IsMultiKey = true
// Disable key 1; only key 0 counts. Cool key 0 -> all (enabled) cooling.
ch.ChannelInfo.MultiKeyStatusList = map[int]int{1: common.ChannelStatusAutoDisabled}
MarkChannelKeyCooldown(202, 0, 30)
if !ch.EnabledKeysAllCoolingDown() {
t.Fatal("only enabled key is cooling -> should report all-cooling")
}
}

// TestGetRandomSatisfiedChannel_SkipsCoolingChannel verifies selection prefers a
// channel with a ready key over one whose only key is cooling down, but falls
// back when every candidate in the tier is cooling.
func TestGetRandomSatisfiedChannel_SkipsCoolingChannel(t *testing.T) {
channelKeyCooldown.Range(func(k, _ any) bool {
channelKeyCooldown.Delete(k)
return true
})
cleanup := setupChannelCache(t, "default", "gpt-x", [][3]int{
{1, 10, 0},
{2, 10, 0},
})
defer cleanup()

// Cool ch1's key -> selection must return ch2 every time.
MarkChannelKeyCooldown(1, 0, 30)
for i := 0; i < 20; i++ {
ch, err := GetRandomSatisfiedChannel("default", "gpt-x", "", nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if ch == nil || ch.Id != 2 {
t.Fatalf("expected ch2 (ch1 cooling), got %v", ch)
}
}

// Cool ch2 as well -> both cooling, must fall back (never deny service).
MarkChannelKeyCooldown(2, 0, 30)
ch, err := GetRandomSatisfiedChannel("default", "gpt-x", "", nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if ch == nil {
t.Fatal("expected a fallback channel when all cooling, got nil")
}
}

// TestGetNextEnabledKey_SkipsCoolingKey verifies multi-key selection avoids a
// cooling key when a ready one exists.
func TestGetNextEnabledKey_SkipsCoolingKey(t *testing.T) {
channelKeyCooldown.Range(func(k, _ any) bool {
channelKeyCooldown.Delete(k)
return true
})
ch := &Channel{Id: 300, Key: "k1\nk2"}
ch.ChannelInfo.IsMultiKey = true
ch.ChannelInfo.MultiKeyMode = constant.MultiKeyModeRandom

// Cool key 0 -> every selection must land on key 1.
MarkChannelKeyCooldown(300, 0, 30)
for i := 0; i < 20; i++ {
_, idx, apiErr := ch.GetNextEnabledKey()
if apiErr != nil {
t.Fatalf("unexpected error: %v", apiErr)
}
if idx != 1 {
t.Fatalf("expected key idx 1 (idx 0 cooling), got %d", idx)
}
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

Both new test files use raw testing assertions instead of the required testify library.

Both files are new Go backend test files. Both use t.Fatal/t.Fatalf directly instead of testify/require and testify/assert. Convert the fatal assertions to require calls and any non-fatal checks to assert calls.

  • model/channel_cooldown_test.go#L11-L174: replace each t.Fatal/t.Fatalf with require.False/require.True/require.Equal/require.Nil, etc., from testify/require.
  • service/retry_after_test.go#L10-L116: replace each t.Fatalf with require.Equal(t, expected, ParseRetryAfterSeconds(h)) (or equivalent), pulling in testify/require.

As per coding guidelines, "New or substantially rewritten Go backend tests must use testify/require for setup and fatal assertions and testify/assert for non-fatal checks."

📍 Affects 2 files
  • model/channel_cooldown_test.go#L11-L174 (this comment)
  • service/retry_after_test.go#L10-L116
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@model/channel_cooldown_test.go` around lines 11 - 174, Replace raw testing
assertions in model/channel_cooldown_test.go lines 11-174 with testify/require
equivalents for fatal checks, using require.True, require.False, require.Equal,
require.NotNil, or require.NoError as appropriate; use assert only for non-fatal
checks, and add the required import. In service/retry_after_test.go lines
10-116, replace every t.Fatalf with testify/require assertions comparing
expected values from ParseRetryAfterSeconds, and add the required import.

Source: Coding guidelines

Comment on lines +13 to +48
func setupChannelCache(t *testing.T, group, modelName string, entries [][3]int) func() {
t.Helper()

oldMemoryCache := common.MemoryCacheEnabled
oldGroup2model := group2model2channels
oldChannelsIDM := channelsIDM
oldAdvanced := channel2advancedCustomConfig

common.MemoryCacheEnabled = true

channelsIDM = make(map[int]*Channel)
ids := make([]int, 0, len(entries))
for _, e := range entries {
id, priority, weight := e[0], e[1], e[2]
p := int64(priority)
w := uint(weight)
channelsIDM[id] = &Channel{
Id: id,
Status: common.ChannelStatusEnabled,
Priority: &p,
Weight: &w,
}
ids = append(ids, id)
}
group2model2channels = map[string]map[string][]int{
group: {modelName: ids},
}
channel2advancedCustomConfig = make(map[int]*dto.AdvancedCustomConfig)

return func() {
common.MemoryCacheEnabled = oldMemoryCache
group2model2channels = oldGroup2model
channelsIDM = oldChannelsIDM
channel2advancedCustomConfig = oldAdvanced
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Reset the key cooldown map in the fixture.

setupChannelCache restores the cache globals but not channelKeyCooldown. model/channel_cooldown_test.go TestGetRandomSatisfiedChannel_SkipsCoolingChannel marks channels 1 and 2 in cooldown for 30 seconds and does not clear them, and these tests reuse the same channel IDs. The assertions currently still pass, but only through the "all cooling → use the full tier" fallback, so the results depend on test order.

Clear the cooldown map in the fixture to make selection deterministic.

💚 Proposed fix
 	common.MemoryCacheEnabled = true
 
+	channelKeyCooldown.Range(func(k, _ any) bool {
+		channelKeyCooldown.Delete(k)
+		return true
+	})
+
 	channelsIDM = make(map[int]*Channel)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
func setupChannelCache(t *testing.T, group, modelName string, entries [][3]int) func() {
t.Helper()
oldMemoryCache := common.MemoryCacheEnabled
oldGroup2model := group2model2channels
oldChannelsIDM := channelsIDM
oldAdvanced := channel2advancedCustomConfig
common.MemoryCacheEnabled = true
channelsIDM = make(map[int]*Channel)
ids := make([]int, 0, len(entries))
for _, e := range entries {
id, priority, weight := e[0], e[1], e[2]
p := int64(priority)
w := uint(weight)
channelsIDM[id] = &Channel{
Id: id,
Status: common.ChannelStatusEnabled,
Priority: &p,
Weight: &w,
}
ids = append(ids, id)
}
group2model2channels = map[string]map[string][]int{
group: {modelName: ids},
}
channel2advancedCustomConfig = make(map[int]*dto.AdvancedCustomConfig)
return func() {
common.MemoryCacheEnabled = oldMemoryCache
group2model2channels = oldGroup2model
channelsIDM = oldChannelsIDM
channel2advancedCustomConfig = oldAdvanced
}
}
func setupChannelCache(t *testing.T, group, modelName string, entries [][3]int) func() {
t.Helper()
oldMemoryCache := common.MemoryCacheEnabled
oldGroup2model := group2model2channels
oldChannelsIDM := channelsIDM
oldAdvanced := channel2advancedCustomConfig
common.MemoryCacheEnabled = true
channelKeyCooldown.Range(func(k, _ any) bool {
channelKeyCooldown.Delete(k)
return true
})
channelsIDM = make(map[int]*Channel)
ids := make([]int, 0, len(entries))
for _, e := range entries {
id, priority, weight := e[0], e[1], e[2]
p := int64(priority)
w := uint(weight)
channelsIDM[id] = &Channel{
Id: id,
Status: common.ChannelStatusEnabled,
Priority: &p,
Weight: &w,
}
ids = append(ids, id)
}
group2model2channels = map[string]map[string][]int{
group: {modelName: ids},
}
channel2advancedCustomConfig = make(map[int]*dto.AdvancedCustomConfig)
return func() {
common.MemoryCacheEnabled = oldMemoryCache
group2model2channels = oldGroup2model
channelsIDM = oldChannelsIDM
channel2advancedCustomConfig = oldAdvanced
}
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@model/channel_failover_test.go` around lines 13 - 48, Update
setupChannelCache to reset the channelKeyCooldown global when initializing the
fixture and restore its previous value in the returned cleanup function,
alongside the other cache globals, so tests reusing channel IDs remain isolated
and deterministic.

Comment on lines +50 to +210
func TestGetRandomSatisfiedChannel_ExcludesFailedChannels(t *testing.T) {
cleanup := setupChannelCache(t, "default", "gpt-x", [][3]int{
{1, 0, 0},
{2, 0, 0},
{3, 0, 0},
})
defer cleanup()

// exclude ch1 -> must return ch2 or ch3, never ch1
for i := 0; i < 20; i++ {
ch, err := GetRandomSatisfiedChannel("default", "gpt-x", "", map[int]bool{1: true})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if ch == nil {
t.Fatal("expected a channel, got nil")
}
if ch.Id == 1 {
t.Fatalf("excluded channel #1 was returned")
}
}

// exclude ch1+ch2 -> must return ch3
ch, err := GetRandomSatisfiedChannel("default", "gpt-x", "", map[int]bool{1: true, 2: true})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if ch == nil || ch.Id != 3 {
t.Fatalf("expected ch3, got %v", ch)
}

// exclude all -> no more channels to try
ch, err = GetRandomSatisfiedChannel("default", "gpt-x", "", map[int]bool{1: true, 2: true, 3: true})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if ch != nil {
t.Fatalf("expected nil when all channels excluded, got #%d", ch.Id)
}
}

// TestGetRandomSatisfiedChannel_PriorityDescentAfterExhaustion verifies bug #6:
// same-priority channels must all be tried before descending to a lower
// priority. ch1/ch2 share priority 10, ch3 has priority 5.
func TestGetRandomSatisfiedChannel_PriorityDescentAfterExhaustion(t *testing.T) {
cleanup := setupChannelCache(t, "default", "gpt-x", [][3]int{
{1, 10, 0},
{2, 10, 0},
{3, 5, 0},
})
defer cleanup()

// exclude ch1 -> must still return ch2 (same top priority), not descend to ch3
for i := 0; i < 20; i++ {
ch, err := GetRandomSatisfiedChannel("default", "gpt-x", "", map[int]bool{1: true})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if ch == nil || ch.Id != 2 {
t.Fatalf("expected ch2 (same priority), got %v", ch)
}
}

// exclude ch1+ch2 -> descend to ch3
ch, err := GetRandomSatisfiedChannel("default", "gpt-x", "", map[int]bool{1: true, 2: true})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if ch == nil || ch.Id != 3 {
t.Fatalf("expected ch3 after top priority exhausted, got %v", ch)
}
}

// TestGetRandomSatisfiedChannel_NilExcludeBackwardCompat verifies that with no
// exclusion (first attempt), the top priority channel pool is used.
func TestGetRandomSatisfiedChannel_NilExcludeBackwardCompat(t *testing.T) {
cleanup := setupChannelCache(t, "default", "gpt-x", [][3]int{
{1, 10, 0},
{2, 5, 0},
})
defer cleanup()

for i := 0; i < 20; i++ {
ch, err := GetRandomSatisfiedChannel("default", "gpt-x", "", nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if ch == nil || ch.Id != 1 {
t.Fatalf("expected top-priority ch1, got %v", ch)
}
}
}

// TestCountAvailableChannels verifies the adaptive retry budget helper counts
// every distinct channel that can serve the group/model, across priority tiers.
// With single-key channels the count equals the number of channels.
func TestCountAvailableChannels(t *testing.T) {
cleanup := setupChannelCache(t, "default", "gpt-x", [][3]int{
{1, 10, 0},
{2, 10, 0},
{3, 5, 0},
})
defer cleanup()

if n := CountAvailableChannels("default", "gpt-x", ""); n != 3 {
t.Fatalf("expected 3 available channels, got %d", n)
}
if n := CountAvailableChannels("default", "nonexistent", ""); n != 0 {
t.Fatalf("expected 0 for unknown model, got %d", n)
}
if n := CountAvailableChannels("nonexistent", "gpt-x", ""); n != 0 {
t.Fatalf("expected 0 for unknown group, got %d", n)
}
}

// TestCountEnabledKeys verifies the per-channel enabled-key count used to size
// how many times a multi-key channel may be re-selected within one request.
func TestCountEnabledKeys(t *testing.T) {
single := &Channel{Id: 1, Key: "k1"}
if n := single.CountEnabledKeys(); n != 1 {
t.Fatalf("single-key channel: expected 1, got %d", n)
}

multi := &Channel{Id: 2, Key: "k1\nk2\nk3"}
multi.ChannelInfo.IsMultiKey = true
if n := multi.CountEnabledKeys(); n != 3 {
t.Fatalf("multi-key all enabled: expected 3, got %d", n)
}

// Disable one key via status list -> count drops to 2.
multi.ChannelInfo.MultiKeyStatusList = map[int]int{1: common.ChannelStatusAutoDisabled}
if n := multi.CountEnabledKeys(); n != 2 {
t.Fatalf("multi-key one disabled: expected 2, got %d", n)
}

empty := &Channel{Id: 3}
empty.ChannelInfo.IsMultiKey = true
if n := empty.CountEnabledKeys(); n != 0 {
t.Fatalf("multi-key no keys: expected 0, got %d", n)
}
}

// TestCountAvailableChannels_MultiKeyWeighted verifies the retry budget counts a
// multi-key channel once per enabled key, so every key can be tried before the
// channel is excluded from retry.
func TestCountAvailableChannels_MultiKeyWeighted(t *testing.T) {
cleanup := setupChannelCache(t, "default", "gpt-x", [][3]int{
{1, 10, 0},
{2, 10, 0},
})
defer cleanup()

// Make ch1 a 3-key channel; ch2 stays single-key. Expected budget: 3 + 1 = 4.
ch1 := channelsIDM[1]
ch1.Key = "k1\nk2\nk3"
ch1.ChannelInfo.IsMultiKey = true

if n := CountAvailableChannels("default", "gpt-x", ""); n != 4 {
t.Fatalf("expected budget 4 (3 keys + 1), got %d", n)
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

New Go tests do not use testify. All three new test files assert with t.Fatal/t.Fatalf only, while the repository requires testify/require for setup and fatal assertions and testify/assert for non-fatal checks.

  • model/channel_failover_test.go#L50-L210: replace the error and identity checks with require.NoError and require.Equal, and use require.Nil/require.NotNil for the channel results.
  • controller/record_channel_failure_test.go#L27-L86: replace the exclusion checks with require.True/require.False and the counter checks with assert.Equal.
  • controller/should_retry_test.go#L45-L73: replace the retry expectations with require.False/require.True, and initialize the retry status-code settings explicitly instead of relying on global defaults.

As per coding guidelines: "New or substantially rewritten Go backend tests must use testify/require for setup and fatal assertions and testify/assert for non-fatal checks; initialize database, request, user, settings, and cache state explicitly".

📍 Affects 3 files
  • model/channel_failover_test.go#L50-L210 (this comment)
  • controller/record_channel_failure_test.go#L27-L86
  • controller/should_retry_test.go#L45-L73
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@model/channel_failover_test.go` around lines 50 - 210, Update
model/channel_failover_test.go lines 50-210 to use testify/require for NoError,
Equal, Nil, and NotNil assertions in the channel failover tests. Update
controller/record_channel_failure_test.go lines 27-86 to use require.True/False
for exclusion checks and assert.Equal for counter checks. Update
controller/should_retry_test.go lines 45-73 to use require.False/True for retry
expectations and explicitly initialize retry status-code settings and relevant
test state instead of relying on global defaults.

Source: Coding guidelines

Comment thread relaykit/types/error.go
Comment on lines +127 to +144
// IsRateLimited reports whether the failure is a per-key rate-limit signal: an
// HTTP 429 or any response carrying an upstream Retry-After / X-RateLimit-Reset
// hint. This is the single source of truth that distinguishes key-level from
// channel-level failures during retry:
//
// - rate-limited (true): only the key just used is throttled; the channel's
// other keys are likely fine, so retry should rotate to the next key and
// the key should enter cooldown for the hinted duration.
// - not rate-limited (false): the failure comes from the channel's upstream
// backend (e.g. 5xx, auth_unavailable). Every key of that channel faces the
// same broken backend, so retry should exclude the whole channel and fail
// over immediately instead of burning the remaining keys.
func (e *NewAPIError) IsRateLimited() bool {
if e == nil {
return false
}
return e.StatusCode == http.StatusTooManyRequests || e.RetryAfterSeconds > 0
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== files matching error.go =="
fd 'error\.go$' . | sed 's#^\./##' | sort

echo "== relaykit/types/error.go outline/section =="
if [ -f relaykit/types/error.go ]; then
  wc -l relaykit/types/error.go
  sed -n '1,180p' relaykit/types/error.go | cat -n
fi

echo "== service/error.go relevant sections =="
if [ -f service/error.go ]; then
  wc -l service/error.go
  rg -n "RelayErrorHandler|RetryAfterSeconds|Retry-After|X-RateLimit-Reset|StatusCode" service/error.go -C 4
fi

echo "== IsRateLimited usages =="
rg -n "IsRateLimited\(" . -g '*.{go,ts,tsx}' -C 3

Repository: QuantumNous/new-api

Length of output: 15600


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== ParseRetryAfterSeconds implementation =="
sed -n '88,152p' service/error.go | cat -n

echo "== RelayErrorHandler end and rate-limit guard if present =="
sed -n '154,240p' service/error.go | cat -n | sed -n '/func RelayErrorHandler/,$p'

echo "== controller/relay.go failure fallback and channel exclusion sections =="
sed -n '410,480p' controller/relay.go | cat -n

echo "== static verifier: IsRateLimited status/RetryAfter truth table for representative inputs =="
python3 - <<'PY'
from dataclasses import dataclass

`@dataclass`
class NewAPIError:
    StatusCode: int
    RetryAfterSeconds: int

def is_rate_limited(e):
    return e.StatusCode == 429 or e.RetryAfterSeconds > 0

cases = [
    # status, retry_after, expected_doc_from_issue
    (429, 0, "true: 429"),
    (503, 60, "true-current: 5xx with header"),
    (503, 0, "false-current: 5xx without header"),
    (401, 60, "true-current: auth with header"),
    (502, 30, "true-current: 5xx with header"),
]
for status, retry_after, note in cases:
    e = NewAPIError(StatusCode=status, RetryAfterSeconds=retry_after)
    print(f"{status} retry_after={retry_after} -> IsRateLimited()={is_rate_limited(e)} ; {note}")
PY

Repository: QuantumNous/new-api

Length of output: 10026


Restrict IsRateLimited() to 429 responses

IsRateLimited() returns true for any error with RetryAfterSeconds > 0, even when StatusCode is 5xx or another non-429 status. RelayErrorHandler sets this field for any response that carries Retry-After or X-RateLimit-Reset, so HTTP-date headers and timestamp-style X-RateLimit-Reset* values also trigger it. These inputs return true while the value may not be a valid cooldown duration.

🐛 Proposed fix to align with the documented rate-limit contract
 func (e *NewAPIError) IsRateLimited() bool {
 	if e == nil {
 		return false
 	}
-	return e.StatusCode == http.StatusTooManyRequests || e.RetryAfterSeconds > 0
+	return e.StatusCode == http.StatusTooManyRequests
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// IsRateLimited reports whether the failure is a per-key rate-limit signal: an
// HTTP 429 or any response carrying an upstream Retry-After / X-RateLimit-Reset
// hint. This is the single source of truth that distinguishes key-level from
// channel-level failures during retry:
//
// - rate-limited (true): only the key just used is throttled; the channel's
// other keys are likely fine, so retry should rotate to the next key and
// the key should enter cooldown for the hinted duration.
// - not rate-limited (false): the failure comes from the channel's upstream
// backend (e.g. 5xx, auth_unavailable). Every key of that channel faces the
// same broken backend, so retry should exclude the whole channel and fail
// over immediately instead of burning the remaining keys.
func (e *NewAPIError) IsRateLimited() bool {
if e == nil {
return false
}
return e.StatusCode == http.StatusTooManyRequests || e.RetryAfterSeconds > 0
}
// IsRateLimited reports whether the failure is a per-key rate-limit signal: an
// HTTP 429 or any response carrying an upstream Retry-After / X-RateLimit-Reset
// hint. This is the single source of truth that distinguishes key-level from
// channel-level failures during retry:
//
// - rate-limited (true): only the key just used is throttled; the channel's
// other keys are likely fine, so retry should rotate to the next key and
// the key should enter cooldown for the hinted duration.
// - not rate-limited (false): the failure comes from the channel's upstream
// backend (e.g. 5xx, auth_unavailable). Every key of that channel faces the
// same broken backend, so retry should exclude the whole channel and fail
// over immediately instead of burning the remaining keys.
func (e *NewAPIError) IsRateLimited() bool {
if e == nil {
return false
}
return e.StatusCode == http.StatusTooManyRequests
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@relaykit/types/error.go` around lines 127 - 144, Update
NewAPIError.IsRateLimited to return true only when StatusCode equals
http.StatusTooManyRequests; remove the RetryAfterSeconds condition so retry
hints on 5xx or other responses cannot classify the error as key-level rate
limiting.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant