fix(proxy): rectifier 不应绕过 maxRetryAttempts#924
Conversation
|
Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
📝 WalkthroughWalkthrough在外层每次迭代中新增整流器切换错误追踪(lastRectifierSwitchError);将单个 provider 的重试决策改为基于思考整流器预算(thinking-signature / thinking-budget);在预算耗尽时记录切换并在无替代 provider 时重新抛出原始错误;新增/调整大量单元测试并导出两个思考预算常量;对序列/尝试计数与端点选择逻辑做出多处内部状态与日志增强。 Changes
Estimated code review effort🎯 3 (中等) | ⏱️ ~25 分钟 Possibly related PRs
🚥 Pre-merge checks | ✅ 2 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (2 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
📝 Coding Plan
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. Comment |
Summary of ChangesHello, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed! 此拉取请求旨在解决代理转发器中一个关键的重试逻辑缺陷。此前,思考签名和思考预算整流器在处理某些错误时,会无视单个供应商配置的最大重试次数,导致不必要的额外请求。本次修复确保了所有重试机制都严格遵循配置的重试限制,并在无法找到替代供应商时,通过抛出更准确的原始错误来改善错误诊断,从而提升了系统的稳定性和可预测性。 Highlights
Changelog
Activity
Using Gemini Code AssistThe full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips. Invoking Gemini You can request assistance from Gemini at any point by creating a comment using either
Customization To customize Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a Limitations & Feedback Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counter productive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for GitHub and other Google products, sign up here. You can also get AI-powered code generation, chat, as well as code reviews directly in the IDE at no cost with the Gemini Code Assist IDE Extension. Footnotes
|
There was a problem hiding this comment.
Code Review
The pull request refactors the ProxyForwarder's 'Thinking signature rectifier' and 'Thinking budget rectifier' logic to strictly adhere to maxRetryAttempts per provider. This ensures that if a rectifier is triggered and the current provider has exhausted its retry budget, the system will attempt to switch to an alternative provider instead of retrying with the same one. Additionally, if no alternative provider is available after a rectifier-triggered switch, the original client-side error (e.g., 400) is now propagated instead of a generic 503, preventing the masking of client input issues. New unit tests were added to validate this behavior for both rectifiers under various maxRetryAttempts scenarios, including cases with and without alternative providers. A minor formatting change was also applied to an integration test file.
There was a problem hiding this comment.
🧹 Nitpick comments (1)
tests/unit/proxy/proxy-forwarder-retry-limit.test.ts (1)
470-473: 考虑导出常量,在测试中使用而非硬编码数值。测试中硬编码了
32000和64000作为期望值。这些值与thinking-budget-rectifier.ts中的MAX_THINKING_BUDGET和MAX_TOKENS_VALUE常量相匹配。然而,这些常量目前未导出,使得在测试中引入它们不可行。如果这两个常量被导出,可以在测试中导入并使用它们,这样在常量值变化时,测试会随之自动调整。可选改进:导出常量后在测试中使用
在
thinking-budget-rectifier.ts中导出常量:+export const MAX_THINKING_BUDGET = 32000; +export const MAX_TOKENS_VALUE = 64000;然后在测试中导入:
+import { + MAX_THINKING_BUDGET, + MAX_TOKENS_VALUE, +} from "@/app/v1/_lib/proxy/thinking-budget-rectifier"; -expect(thinking.budget_tokens).toBe(32000); -expect((session.request.message as any).max_tokens).toBe(64000); +expect(thinking.budget_tokens).toBe(MAX_THINKING_BUDGET); +expect((session.request.message as any).max_tokens).toBe(MAX_TOKENS_VALUE);🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@tests/unit/proxy/proxy-forwarder-retry-limit.test.ts` around lines 470 - 473, Export the constants MAX_THINKING_BUDGET and MAX_TOKENS_VALUE from thinking-budget-rectifier.ts and update the test to import and use those exports instead of the hardcoded numbers; specifically, add exports for MAX_THINKING_BUDGET and MAX_TOKENS_VALUE in the module that defines them and replace the literal 32000 and 64000 in proxy-forwarder-retry-limit.test.ts with the imported MAX_THINKING_BUDGET and MAX_TOKENS_VALUE so the test follows the canonical source of truth.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Nitpick comments:
In `@tests/unit/proxy/proxy-forwarder-retry-limit.test.ts`:
- Around line 470-473: Export the constants MAX_THINKING_BUDGET and
MAX_TOKENS_VALUE from thinking-budget-rectifier.ts and update the test to import
and use those exports instead of the hardcoded numbers; specifically, add
exports for MAX_THINKING_BUDGET and MAX_TOKENS_VALUE in the module that defines
them and replace the literal 32000 and 64000 in
proxy-forwarder-retry-limit.test.ts with the imported MAX_THINKING_BUDGET and
MAX_TOKENS_VALUE so the test follows the canonical source of truth.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: 18bcbbb9-cf12-4ae8-af5a-87ccd7dba2ef
📒 Files selected for processing (3)
src/app/v1/_lib/proxy/forwarder.tstests/integration/usage-ledger.test.tstests/unit/proxy/proxy-forwarder-retry-limit.test.ts
There was a problem hiding this comment.
Code Review Summary
This PR fixes a bug where max_retry_attempts configuration was being bypassed by the thinking signature and budget rectifiers. The fix ensures rectifiers respect the configured retry limits and properly fallback to throwing the original 400 error (instead of 503) when no alternative provider exists.
PR Size: M
- Lines changed: 556 additions, 68 deletions
- Files changed: 3
Issues Found
| Category | Critical | High | Medium | Low |
|---|---|---|---|---|
| Logic/Bugs | 0 | 0 | 0 | 0 |
| Security | 0 | 0 | 0 | 0 |
| Error Handling | 0 | 0 | 0 | 0 |
| Types | 0 | 0 | 0 | 0 |
| Comments/Docs | 0 | 0 | 0 | 0 |
| Tests | 0 | 0 | 0 | 0 |
| Simplification | 0 | 0 | 0 | 0 |
Critical Issues (Must Fix)
None identified.
High Priority Issues (Should Fix)
None identified.
Review Coverage
- Logic and correctness - Clean
- Security (OWASP Top 10) - Clean
- Error handling - Clean
- Type safety - Clean
- Documentation accuracy - Clean
- Test coverage - Adequate (8 new comprehensive unit tests)
- Code clarity - Good
Test Summary
The PR includes 8 new unit tests covering:
- Thinking signature rectifier with maxRetryAttempts=1 (should switch provider, not retry same)
- Thinking budget rectifier with maxRetryAttempts=1 (should switch provider, not retry same)
- No alternative provider should throw original 400 (not 503) - for both rectifiers
- maxRetryAttempts=2 should retry same provider (not switch) - for both rectifiers
All tests properly validate the fix using mocked dependencies and fake timers.
Automated review by Claude AI
13810df to
e59ff5a
Compare
| if (lastRectifierSwitchError) { | ||
| throw lastRectifierSwitchError; | ||
| } |
There was a problem hiding this comment.
Missing session provider binding cleanup
When lastRectifierSwitchError is thrown here, execution exits the outer while loop via throw, completely bypassing the clearSessionProviderBinding(session) call at line 1854. Every other error-exit path in this method calls clearSessionProviderBinding before propagating an error (e.g. client-abort at line 1053, and the all-providers-failed path at line 1854). Skipping it here means SessionManager.clearSessionProvider(session.sessionId) is never invoked, potentially leaving a stale provider binding in the session store that could affect future requests on the same session.
| if (lastRectifierSwitchError) { | |
| throw lastRectifierSwitchError; | |
| } | |
| if (!alternativeProvider) { | |
| if (lastRectifierSwitchError) { | |
| await ProxyForwarder.clearSessionProviderBinding(session); | |
| throw lastRectifierSwitchError; | |
| } |
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/app/v1/_lib/proxy/forwarder.ts
Line: 1817-1819
Comment:
**Missing session provider binding cleanup**
When `lastRectifierSwitchError` is thrown here, execution exits the outer `while` loop via `throw`, completely bypassing the `clearSessionProviderBinding(session)` call at line 1854. Every other error-exit path in this method calls `clearSessionProviderBinding` before propagating an error (e.g. client-abort at line 1053, and the all-providers-failed path at line 1854). Skipping it here means `SessionManager.clearSessionProvider(session.sessionId)` is never invoked, potentially leaving a stale provider binding in the session store that could affect future requests on the same session.
```suggestion
if (!alternativeProvider) {
if (lastRectifierSwitchError) {
await ProxyForwarder.clearSessionProviderBinding(session);
throw lastRectifierSwitchError;
}
```
How can I resolve this? If you propose a fix, please make it concise.| expect(doForward).toHaveBeenCalledTimes(1); | ||
| expect(selectAlternative).toHaveBeenCalledTimes(1); | ||
| } finally { | ||
| vi.useRealTimers(); | ||
| } | ||
| }); | ||
|
|
||
| test("thinking budget rectifier: maxRetryAttempts=1 + no alternative provider should throw original 400 (not 503)", async () => { | ||
| vi.useFakeTimers(); | ||
|
|
||
| try { | ||
| const session = createSession(); |
There was a problem hiding this comment.
Potentially unreliable caught variable check after vi.runAllTimersAsync()
The test attaches a .catch() callback, awaits vi.runAllTimersAsync(), and then inspects the caught variable. The .catch() callback itself is resolved as a microtask when sendPromise rejects; there is no guarantee it has run by the time the expect line executes — this is a subtle but well-known race in promise-based test patterns.
The same pattern is used in three other tests in this file (the two "no alternative provider should throw" cases and the budget rectifier variant).
Prefer the standard Vitest idiom that properly awaits the full promise rejection:
// Instead of:
const sendPromise = ProxyForwarder.send(session);
let caught: unknown = null;
sendPromise.catch((error) => {
caught = error;
});
await vi.runAllTimersAsync();
expect(caught).toBeInstanceOf(ProxyError);
expect((caught as ProxyError).statusCode).toBe(400);
// Use:
const sendPromise = ProxyForwarder.send(session);
vi.runAllTimersAsync(); // don't await — let the underlying async work progress
await expect(sendPromise).rejects.toBeInstanceOf(ProxyError);
await expect(sendPromise).rejects.toMatchObject({ statusCode: 400 });Prompt To Fix With AI
This is a comment left during a code review.
Path: tests/unit/proxy/proxy-forwarder-retry-limit.test.ts
Line: 537-548
Comment:
**Potentially unreliable `caught` variable check after `vi.runAllTimersAsync()`**
The test attaches a `.catch()` callback, awaits `vi.runAllTimersAsync()`, and then inspects the `caught` variable. The `.catch()` callback itself is resolved as a microtask when `sendPromise` rejects; there is no guarantee it has run by the time the `expect` line executes — this is a subtle but well-known race in promise-based test patterns.
The same pattern is used in three other tests in this file (the two "no alternative provider should throw" cases and the budget rectifier variant).
Prefer the standard Vitest idiom that properly awaits the full promise rejection:
```typescript
// Instead of:
const sendPromise = ProxyForwarder.send(session);
let caught: unknown = null;
sendPromise.catch((error) => {
caught = error;
});
await vi.runAllTimersAsync();
expect(caught).toBeInstanceOf(ProxyError);
expect((caught as ProxyError).statusCode).toBe(400);
// Use:
const sendPromise = ProxyForwarder.send(session);
vi.runAllTimersAsync(); // don't await — let the underlying async work progress
await expect(sendPromise).rejects.toBeInstanceOf(ProxyError);
await expect(sendPromise).rejects.toMatchObject({ statusCode: 400 });
```
How can I resolve this? If you propose a fix, please make it concise.There was a problem hiding this comment.
🧹 Nitpick comments (1)
src/app/v1/_lib/proxy/forwarder.ts (1)
1162-1236: 建议抽取 rectifier 重试预算处理的公共逻辑。signature / budget 两段分支的预算判定、日志与切换路径几乎一致,后续容易出现单侧修复遗漏。建议抽成一个内部 helper,降低维护风险。
♻️ 可选重构示例
+type RectifierBudgetDecision = "retry_same_provider" | "switch_provider"; + +function decideRectifierBudgetAction( + attemptCount: number, + maxAttemptsPerProvider: number +): RectifierBudgetDecision { + return attemptCount < maxAttemptsPerProvider ? "retry_same_provider" : "switch_provider"; +}-const hasRetryBudget = attemptCount < maxAttemptsPerProvider; -if (hasRetryBudget) { - // retry logs... - thinkingBudgetRectifierRetried = true; - continue; -} -lastRectifierSwitchError = lastError; -failedProviderIds.push(currentProvider.id); -break; +const action = decideRectifierBudgetAction(attemptCount, maxAttemptsPerProvider); +if (action === "retry_same_provider") { + // retry logs... + thinkingBudgetRectifierRetried = true; + continue; +} +lastRectifierSwitchError = lastError; +failedProviderIds.push(currentProvider.id); +break;Also applies to: 1316-1393
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/app/v1/_lib/proxy/forwarder.ts` around lines 1162 - 1236, The retry-budget and switch logic for the rectifier blocks is duplicated; extract a helper (e.g., handleRetryBudgetForRectifier) that accepts parameters (currentProvider, attemptCount, maxAttemptsPerProvider, rectifierTrigger, lastError, endpointAudit, requestDetailsBeforeRectify, getCircuitState, session) and encapsulates: computing hasRetryBudget, emitting the two logger.info variants, recording the provider via session.addProviderToChain (handling ProxyError vs generic error shapes), and returning a control signal (retry | switch) plus any metadata (e.g., lastRectifierSwitchError candidate, failedProviderId). Replace the duplicated branches around thinkingSignatureRectifierRetried/continue and lastRectifierSwitchError/failedProviderIds with calls to this helper in both the thinking signature and signature rectifier places to centralize behavior and reduce drift.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Nitpick comments:
In `@src/app/v1/_lib/proxy/forwarder.ts`:
- Around line 1162-1236: The retry-budget and switch logic for the rectifier
blocks is duplicated; extract a helper (e.g., handleRetryBudgetForRectifier)
that accepts parameters (currentProvider, attemptCount, maxAttemptsPerProvider,
rectifierTrigger, lastError, endpointAudit, requestDetailsBeforeRectify,
getCircuitState, session) and encapsulates: computing hasRetryBudget, emitting
the two logger.info variants, recording the provider via
session.addProviderToChain (handling ProxyError vs generic error shapes), and
returning a control signal (retry | switch) plus any metadata (e.g.,
lastRectifierSwitchError candidate, failedProviderId). Replace the duplicated
branches around thinkingSignatureRectifierRetried/continue and
lastRectifierSwitchError/failedProviderIds with calls to this helper in both the
thinking signature and signature rectifier places to centralize behavior and
reduce drift.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: 1b21a613-f5da-4ca5-bb03-65ab79e6a9c2
📒 Files selected for processing (2)
src/app/v1/_lib/proxy/forwarder.tssrc/app/v1/_lib/proxy/thinking-budget-rectifier.ts
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@tests/unit/proxy/proxy-forwarder-retry-limit.test.ts`:
- Around line 17-56: The mock for getCachedSystemSettings is missing the
required SystemSettings field enableResponseInputRectifier; update the async
mock return object in getCachedSystemSettings to include
enableResponseInputRectifier: true (matching DEFAULT_SETTINGS) so the mocked
value conforms to the SystemSettings interface and mirrors DEFAULT_SETTINGS.
🪄 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
Run ID: 55f4d5dd-79a1-454f-839a-937dc642a23a
📒 Files selected for processing (2)
src/app/v1/_lib/proxy/forwarder.tstests/unit/proxy/proxy-forwarder-retry-limit.test.ts
| providerName: currentProvider.name, | ||
| trigger: rectifierTrigger, | ||
| attemptNumber: attemptCount, | ||
| retryAttemptNumber: attemptCount + 1, | ||
| retryAttemptNumber: hasRetryBudget ? attemptCount + 1 : attemptCount, |
There was a problem hiding this comment.
retryAttemptNumber is misleading when no retry is planned
When !hasRetryBudget, retryAttemptNumber is set to attemptCount — the same value as attemptNumber. This makes the audit record look as if a retry was attempted at the current attempt number, rather than clearly communicating "no retry occurred." A reader scanning the audit log has no way to distinguish "retry happened at attempt N" from "no retry, field is a fallback."
Consider using a sentinel value such as null (or omitting the field) when no retry will be performed:
| providerName: currentProvider.name, | |
| trigger: rectifierTrigger, | |
| attemptNumber: attemptCount, | |
| retryAttemptNumber: attemptCount + 1, | |
| retryAttemptNumber: hasRetryBudget ? attemptCount + 1 : attemptCount, | |
| attemptNumber: attemptCount, | |
| retryAttemptNumber: hasRetryBudget ? attemptCount + 1 : null, |
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/app/v1/_lib/proxy/forwarder.ts
Line: 1112-1115
Comment:
**`retryAttemptNumber` is misleading when no retry is planned**
When `!hasRetryBudget`, `retryAttemptNumber` is set to `attemptCount` — the same value as `attemptNumber`. This makes the audit record look as if a retry was attempted at the current attempt number, rather than clearly communicating "no retry occurred." A reader scanning the audit log has no way to distinguish "retry happened at attempt N" from "no retry, field is a fallback."
Consider using a sentinel value such as `null` (or omitting the field) when no retry will be performed:
```suggestion
attemptNumber: attemptCount,
retryAttemptNumber: hasRetryBudget ? attemptCount + 1 : null,
```
How can I resolve this? If you propose a fix, please make it concise.| trigger: budgetRectifierTrigger, | ||
| attemptNumber: attemptCount, | ||
| retryAttemptNumber: attemptCount + 1, | ||
| retryAttemptNumber: hasRetryBudget ? attemptCount + 1 : attemptCount, |
There was a problem hiding this comment.
Same retryAttemptNumber ambiguity in budget rectifier audit
The thinking budget rectifier has the same semantic issue. When !hasRetryBudget, retryAttemptNumber: attemptCount is indistinguishable in the audit log from a retry that actually happened at attempt N. Using null (or omitting) when no retry is planned would make the audit semantics unambiguous.
| trigger: budgetRectifierTrigger, | |
| attemptNumber: attemptCount, | |
| retryAttemptNumber: attemptCount + 1, | |
| retryAttemptNumber: hasRetryBudget ? attemptCount + 1 : attemptCount, | |
| attemptNumber: attemptCount, | |
| retryAttemptNumber: hasRetryBudget ? attemptCount + 1 : null, |
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/app/v1/_lib/proxy/forwarder.ts
Line: 1268-1270
Comment:
**Same `retryAttemptNumber` ambiguity in budget rectifier audit**
The thinking budget rectifier has the same semantic issue. When `!hasRetryBudget`, `retryAttemptNumber: attemptCount` is indistinguishable in the audit log from a retry that actually happened at attempt N. Using `null` (or omitting) when no retry is planned would make the audit semantics unambiguous.
```suggestion
attemptNumber: attemptCount,
retryAttemptNumber: hasRetryBudget ? attemptCount + 1 : null,
```
How can I resolve this? If you propose a fix, please make it concise.| if (!alternativeProvider) { | ||
| if (lastRectifierSwitchError) { | ||
| await ProxyForwarder.clearSessionProviderBinding(session); | ||
| throw lastRectifierSwitchError; | ||
| } |
There was a problem hiding this comment.
lastRectifierSwitchError is reset before each alternative provider is tried, silently discarding the original 400 in chains of ≥ 3 providers
lastRectifierSwitchError is nulled out at line 548 (lastRectifierSwitchError = null) at the top of every outer-loop iteration. This means:
- Provider A — rectifier fires,
!hasRetryBudget→lastRectifierSwitchError = 400 error, break, select provider B. - Provider B outer iteration starts →
lastRectifierSwitchError = null(reset). - Provider B fails for any non-rectifier reason → break,
selectAlternativereturnsnull. lastRectifierSwitchErrorisnull→ falls through tobuildAllProvidersUnavailableError()→ 503 thrown, even though the root cause was a client 400.
For single-provider or two-provider setups this is fine (and is covered by the new tests). However, in deployments with ≥ 3 providers configured, the original 400 can be silently swallowed if an intermediate provider also exhausts its attempts.
It may be acceptable to preserve only the most-recent rectifier error (current behavior) rather than the earliest one, but the discarding is invisible and there is no test exercising this path. Adding a test (or a comment explaining the design choice) would prevent future regressions.
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/app/v1/_lib/proxy/forwarder.ts
Line: 1814-1818
Comment:
**`lastRectifierSwitchError` is reset before each alternative provider is tried, silently discarding the original 400 in chains of ≥ 3 providers**
`lastRectifierSwitchError` is nulled out at line 548 (`lastRectifierSwitchError = null`) at the top of every outer-loop iteration. This means:
1. Provider A — rectifier fires, `!hasRetryBudget` → `lastRectifierSwitchError = 400 error`, break, select provider B.
2. Provider B outer iteration starts → `lastRectifierSwitchError = null` (reset).
3. Provider B fails for any non-rectifier reason → break, `selectAlternative` returns `null`.
4. `lastRectifierSwitchError` is `null` → falls through to `buildAllProvidersUnavailableError()` → **503 thrown**, even though the root cause was a client 400.
For single-provider or two-provider setups this is fine (and is covered by the new tests). However, in deployments with ≥ 3 providers configured, the original 400 can be silently swallowed if an intermediate provider also exhausts its attempts.
It may be acceptable to preserve only the most-recent rectifier error (current behavior) rather than the earliest one, but the discarding is invisible and there is no test exercising this path. Adding a test (or a comment explaining the design choice) would prevent future regressions.
How can I resolve this? If you propose a fix, please make it concise.| const sendPromise = ProxyForwarder.send(session); | ||
| void vi.runAllTimersAsync(); | ||
| await expect(sendPromise).rejects.toBeInstanceOf(ProxyError); | ||
| await expect(sendPromise).rejects.toMatchObject({ statusCode: 400 }); |
There was a problem hiding this comment.
void vi.runAllTimersAsync() is inconsistent with the success-path test pattern
The success-path tests (e.g., the "maxRetryAttempts=1 should switch provider" tests above) use await vi.runAllTimersAsync(), which guarantees all fake timers have advanced before the next assertion. Here the "no-alternative" tests use void (fire-and-forget) and immediately await expect(sendPromise).rejects....
This works in practice because the rejection does not depend on any timers, but it creates an inconsistency that may confuse future test authors. For uniformity and clarity, prefer:
const sendPromise = ProxyForwarder.send(session);
await vi.runAllTimersAsync();
await expect(sendPromise).rejects.toBeInstanceOf(ProxyError);
await expect(sendPromise).rejects.toMatchObject({ statusCode: 400 });Prompt To Fix With AI
This is a comment left during a code review.
Path: tests/unit/proxy/proxy-forwarder-retry-limit.test.ts
Line: 528-531
Comment:
**`void vi.runAllTimersAsync()` is inconsistent with the success-path test pattern**
The success-path tests (e.g., the "maxRetryAttempts=1 should switch provider" tests above) use `await vi.runAllTimersAsync()`, which guarantees all fake timers have advanced before the next assertion. Here the "no-alternative" tests use `void` (fire-and-forget) and immediately `await expect(sendPromise).rejects...`.
This works in practice because the rejection does not depend on any timers, but it creates an inconsistency that may confuse future test authors. For uniformity and clarity, prefer:
```typescript
const sendPromise = ProxyForwarder.send(session);
await vi.runAllTimersAsync();
await expect(sendPromise).rejects.toBeInstanceOf(ProxyError);
await expect(sendPromise).rejects.toMatchObject({ statusCode: 400 });
```
How can I resolve this? If you propose a fix, please make it concise.Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!
There was a problem hiding this comment.
🧹 Nitpick comments (3)
tests/unit/proxy/proxy-forwarder-retry-limit.test.ts (3)
529-534: 建议使用await替代void以避免潜在的竞态条件第 530 行使用
void vi.runAllTimersAsync()而非await vi.runAllTimersAsync()。由于runAllTimersAsync返回 Promise,使用void忽略返回值可能导致定时器未完全推进就开始执行断言,从而产生不稳定的测试结果。文件中其他测试(如第 386、463 行)都正确使用了
await vi.runAllTimersAsync()。♻️ 建议修复
const sendPromise = ProxyForwarder.send(session); - void vi.runAllTimersAsync(); + await vi.runAllTimersAsync(); await expect(sendPromise).rejects.toBeInstanceOf(ProxyError); await expect(sendPromise).rejects.toMatchObject({ statusCode: 400 });🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@tests/unit/proxy/proxy-forwarder-retry-limit.test.ts` around lines 529 - 534, The test uses "void vi.runAllTimersAsync()" after calling ProxyForwarder.send(session), which discards the promise and can produce race conditions; replace the void usage with "await vi.runAllTimersAsync()" so timers are fully advanced before asserting on sendPromise (references: ProxyForwarder.send and vi.runAllTimersAsync in the failing test block).
574-579: 同上,建议使用await替代void第 575 行存在与第 530 行相同的问题。建议统一使用
await vi.runAllTimersAsync()以确保测试稳定性。♻️ 建议修复
const sendPromise = ProxyForwarder.send(session); - void vi.runAllTimersAsync(); + await vi.runAllTimersAsync(); await expect(sendPromise).rejects.toBeInstanceOf(ProxyError); await expect(sendPromise).rejects.toMatchObject({ statusCode: 400 });🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@tests/unit/proxy/proxy-forwarder-retry-limit.test.ts` around lines 574 - 579, The test uses "void vi.runAllTimersAsync()" which can hide promise rejections and cause flakiness; replace it with "await vi.runAllTimersAsync()" in the test that calls ProxyForwarder.send(session) so the timers finish before assertions. Update the invocation at the block containing ProxyForwarder.send(session) (and the related assertions on ProxyError, statusCode, doForward and selectAlternative) to await vi.runAllTimersAsync() to ensure the promise settles before the expects.
844-865: 冗余的mockImplementation可能导致理解困惑第 844-846 行的
mockImplementation设置了默认行为,但由于后续添加了恰好 5 个排队的实现(4 个mockImplementationOnce+ 1 个mockResolvedValueOnce),且测试预期正好 5 次调用,该默认实现永远不会被执行。建议移除该冗余设置以提高代码可读性。
♻️ 建议修复
- // All attempts fail except the last one - doForward.mockImplementation(async () => { - throw networkError; - }); - // 5th attempt succeeds - doForward.mockImplementationOnce(async () => { + // First 4 attempts fail, 5th succeeds + doForward.mockImplementationOnce(async () => { throw networkError; });🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@tests/unit/proxy/proxy-forwarder-retry-limit.test.ts` around lines 844 - 865, 删除冗余的默认 mock 行:移除对 doForward.mockImplementation(async () => { throw networkError; }); 的调用,只保留后续用到的 doForward.mockImplementationOnce(...) 和 doForward.mockResolvedValueOnce(...) 排队实现,以确保测试使用正好 5 次排队的返回行为(函数名:doForward,方法:mockImplementation、mockImplementationOnce、mockResolvedValueOnce)。
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Nitpick comments:
In `@tests/unit/proxy/proxy-forwarder-retry-limit.test.ts`:
- Around line 529-534: The test uses "void vi.runAllTimersAsync()" after calling
ProxyForwarder.send(session), which discards the promise and can produce race
conditions; replace the void usage with "await vi.runAllTimersAsync()" so timers
are fully advanced before asserting on sendPromise (references:
ProxyForwarder.send and vi.runAllTimersAsync in the failing test block).
- Around line 574-579: The test uses "void vi.runAllTimersAsync()" which can
hide promise rejections and cause flakiness; replace it with "await
vi.runAllTimersAsync()" in the test that calls ProxyForwarder.send(session) so
the timers finish before assertions. Update the invocation at the block
containing ProxyForwarder.send(session) (and the related assertions on
ProxyError, statusCode, doForward and selectAlternative) to await
vi.runAllTimersAsync() to ensure the promise settles before the expects.
- Around line 844-865: 删除冗余的默认 mock 行:移除对 doForward.mockImplementation(async ()
=> { throw networkError; }); 的调用,只保留后续用到的 doForward.mockImplementationOnce(...)
和 doForward.mockResolvedValueOnce(...) 排队实现,以确保测试使用正好 5
次排队的返回行为(函数名:doForward,方法:mockImplementation、mockImplementationOnce、mockResolvedValueOnce)。
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: c493e1f0-bd20-4315-bf3b-f5b9805ab337
📒 Files selected for processing (1)
tests/unit/proxy/proxy-forwarder-retry-limit.test.ts
| @@ -1207,15 +1223,23 @@ export class ProxyForwarder { | |||
| }); | |||
| } | |||
|
|
|||
| // 确保即使 maxAttemptsPerProvider=1 也能完成一次额外重试 | |||
| maxAttemptsPerProvider = Math.max(maxAttemptsPerProvider, attemptCount + 1); | |||
| continue; | |||
| // 尊重“单供应商最大尝试次数”:只在还有预算时才对同一供应商重试;否则直接进入供应商切换逻辑。 | |||
| if (hasRetryBudget) { | |||
| thinkingSignatureRectifierRetried = true; | |||
| continue; | |||
| } | |||
|
|
|||
| // 记录原始错误:若无可用替代供应商,回退抛出该错误而非 503,避免掩盖客户端输入问题。 | |||
| lastRectifierSwitchError = lastError; | |||
| failedProviderIds.push(currentProvider.id); | |||
| break; | |||
There was a problem hiding this comment.
reason: "retry_failed" is incorrect when no retry is performed
The addProviderToChain call at this block unconditionally uses reason: "retry_failed" for the audit record, but this block is shared between two semantically different outcomes:
hasRetryBudget == true→ a retry actually happens (continue) →"retry_failed"is accuratehasRetryBudget == false→ no retry happens, we immediately break to switch providers →"retry_failed"is misleading
When the !hasRetryBudget path is taken, the audit chain records "retry_failed" even though only a single attempt was made before the provider switch. This creates incorrect audit data: an operator analyzing the chain will see "retry_failed" implying two attempts were made, when in fact only one occurred.
The same issue exists in the budget rectifier block at ~line 1342–1380.
A dedicated reason — e.g. "rectifier_triggered_failover" — should be used when !hasRetryBudget, to accurately reflect that rectification was applied but budget was exhausted, triggering a provider switch rather than a retry:
// After audit/chain recording, before the hasRetryBudget branch
if (hasRetryBudget) {
// reason "retry_failed" is correct: we recorded the failure and are about to retry
thinkingSignatureRectifierRetried = true;
continue;
}
// Change the already-recorded chain entry's reason, or record a separate entry
// with a distinct reason like "rectifier_triggered_failover"
lastRectifierSwitchError = lastError;
failedProviderIds.push(currentProvider.id);
break;The same fix should be applied to the budget rectifier at ~line 1342.
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/app/v1/_lib/proxy/forwarder.ts
Line: 1185-1235
Comment:
**`reason: "retry_failed"` is incorrect when no retry is performed**
The `addProviderToChain` call at this block unconditionally uses `reason: "retry_failed"` for the audit record, but this block is shared between two semantically different outcomes:
1. `hasRetryBudget == true` → a retry actually happens (`continue`) → `"retry_failed"` is accurate
2. `hasRetryBudget == false` → **no retry happens**, we immediately break to switch providers → `"retry_failed"` is misleading
When the `!hasRetryBudget` path is taken, the audit chain records `"retry_failed"` even though only a single attempt was made before the provider switch. This creates incorrect audit data: an operator analyzing the chain will see `"retry_failed"` implying two attempts were made, when in fact only one occurred.
The same issue exists in the budget rectifier block at ~line 1342–1380.
A dedicated reason — e.g. `"rectifier_triggered_failover"` — should be used when `!hasRetryBudget`, to accurately reflect that rectification was applied but budget was exhausted, triggering a provider switch rather than a retry:
```typescript
// After audit/chain recording, before the hasRetryBudget branch
if (hasRetryBudget) {
// reason "retry_failed" is correct: we recorded the failure and are about to retry
thinkingSignatureRectifierRetried = true;
continue;
}
// Change the already-recorded chain entry's reason, or record a separate entry
// with a distinct reason like "rectifier_triggered_failover"
lastRectifierSwitchError = lastError;
failedProviderIds.push(currentProvider.id);
break;
```
The same fix should be applied to the budget rectifier at ~line 1342.
How can I resolve this? If you propose a fix, please make it concise.There was a problem hiding this comment.
Actionable comments posted: 2
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@src/app/v1/_lib/proxy/forwarder.ts`:
- Line 1115: The code currently always sets retryAttemptNumber (in the object
created near retryAttemptNumber: hasRetryBudget ? attemptCount + 1 :
attemptCount) even when hasRetryBudget is false, which incorrectly records a
non-existent same-provider retry; change the logic so that when hasRetryBudget
=== false the retryAttemptNumber property is omitted (or set to undefined)
instead of being populated with attemptCount, and apply the same fix to the
other occurrence that mirrors this logic (the second similar assignment around
retryAttemptNumber). Locate the object construction using retryAttemptNumber,
the variables hasRetryBudget and attemptCount, and update it to only include
retryAttemptNumber when hasRetryBudget is true (or explicitly record a
provider-switch field if you need to represent failover separately).
- Around line 1075-1082: The rectifier (thinking-signature/thinking-budget) and
failover decision is only applied in send()'s retry loop, so streaming requests
via sendStreamingWithHedge() (which uses handleAttemptFailure() and
categorizeErrorAsync()) skip it; move the rectifier/failover logic into the
shared failure-handling path used by both hedge and non-hedge flows (e.g., unify
into handleAttemptFailure() or a new helper called from both send() and
sendStreamingWithHedge()), ensure the logic checks isAnthropicProvider and
respects maxRetryAttempts there, and add an explicit branch to handle stream:
true so streaming Claude calls get the same rectifier + failover behavior as
non-streaming calls.
🪄 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
Run ID: b756f5b2-7d23-4b11-814d-9220c98c96b2
📒 Files selected for processing (2)
src/app/v1/_lib/proxy/forwarder.tstests/unit/proxy/proxy-forwarder-retry-limit.test.ts
| recordFailure: mocks.recordFailure, | ||
| })); | ||
|
|
||
| vi.mock("@/lib/config", async (importOriginal) => { | ||
| const actual = await importOriginal<typeof import("@/lib/config")>(); | ||
| return { | ||
| ...actual, | ||
| getCachedSystemSettings: mocks.getCachedSystemSettings, | ||
| isHttp2Enabled: mocks.isHttp2Enabled, | ||
| }; |
There was a problem hiding this comment.
vi.clearAllMocks() does not reset mock implementations — stale return values may leak between tests
vi.clearAllMocks() (called in each beforeEach) only clears recorded calls, instances, and results. It does not reset implementations set via .mockResolvedValue() or .mockImplementation(). Because vi.spyOn(..., "selectAlternative") is created freshly inside each test body this is benign today — the fresh spyOn replaces the previous spy on the class and has no default implementation. However, doForward spies created inside each test that use .mockImplementationOnce() also benefit from fresh spies, so there's no bleed-over.
The concern is that a future test author might introduce a spy at describe-block scope (rather than inside the test body) and then rely on vi.clearAllMocks() to reset its return value, which would not work. Consider adding a comment or using vi.resetAllMocks() instead of vi.clearAllMocks() in the beforeEach of this new describe block to make the intent explicit.
| recordFailure: mocks.recordFailure, | |
| })); | |
| vi.mock("@/lib/config", async (importOriginal) => { | |
| const actual = await importOriginal<typeof import("@/lib/config")>(); | |
| return { | |
| ...actual, | |
| getCachedSystemSettings: mocks.getCachedSystemSettings, | |
| isHttp2Enabled: mocks.isHttp2Enabled, | |
| }; | |
| beforeEach(() => { | |
| vi.resetAllMocks(); | |
| }); |
Prompt To Fix With AI
This is a comment left during a code review.
Path: tests/unit/proxy/proxy-forwarder-retry-limit.test.ts
Line: 90-99
Comment:
**`vi.clearAllMocks()` does not reset mock implementations — stale return values may leak between tests**
`vi.clearAllMocks()` (called in each `beforeEach`) only clears recorded calls, instances, and results. It does **not** reset implementations set via `.mockResolvedValue()` or `.mockImplementation()`. Because `vi.spyOn(..., "selectAlternative")` is created freshly inside each test body this is benign today — the fresh `spyOn` replaces the previous spy on the class and has no default implementation. However, `doForward` spies created inside each test that use `.mockImplementationOnce()` also benefit from fresh spies, so there's no bleed-over.
The concern is that a future test author might introduce a spy at describe-block scope (rather than inside the test body) and then rely on `vi.clearAllMocks()` to reset its return value, which would not work. Consider adding a comment or using `vi.resetAllMocks()` instead of `vi.clearAllMocks()` in the `beforeEach` of this new describe block to make the intent explicit.
```suggestion
beforeEach(() => {
vi.resetAllMocks();
});
```
How can I resolve this? If you propose a fix, please make it concise.| // 尊重“单供应商最大尝试次数”:只在还有预算时才对同一供应商重试;否则直接进入供应商切换逻辑。 | ||
| if (hasRetryBudget) { | ||
| thinkingSignatureRectifierRetried = true; | ||
| continue; | ||
| } | ||
|
|
||
| // 记录原始错误:若无可用替代供应商,回退抛出该错误而非 503,避免掩盖客户端输入问题。 | ||
| lastRectifierSwitchError = lastError; | ||
| failedProviderIds.push(currentProvider.id); | ||
| break; |
There was a problem hiding this comment.
Multi-endpoint provider skips remaining endpoints on rectifier failover
When !hasRetryBudget causes the inner loop to break, only the first endpoint of currentProvider has been tried. For providers configured with multiple endpoints (e.g. a vendor with a latency-sorted endpoint pool), the remaining endpoints for that provider are silently skipped.
This is appropriate in this case because the 400 error is a client-input issue (invalid thinking signature/budget) — trying a different endpoint on the same provider would not help. A brief comment here (and in the analogous budget-rectifier block at ~line 1382) would make this intent explicit for future readers, since elsewhere in the file "break" from the inner loop implies "all endpoints for this provider have been exhausted".
| // 尊重“单供应商最大尝试次数”:只在还有预算时才对同一供应商重试;否则直接进入供应商切换逻辑。 | |
| if (hasRetryBudget) { | |
| thinkingSignatureRectifierRetried = true; | |
| continue; | |
| } | |
| // 记录原始错误:若无可用替代供应商,回退抛出该错误而非 503,避免掩盖客户端输入问题。 | |
| lastRectifierSwitchError = lastError; | |
| failedProviderIds.push(currentProvider.id); | |
| break; | |
| // NOTE: We intentionally skip remaining endpoints for this provider (if any). | |
| // The 400 error is a client-input issue that would reproduce on every endpoint, | |
| // so endpoint-level failover would not help. | |
| lastRectifierSwitchError = lastError; | |
| failedProviderIds.push(currentProvider.id); | |
| break; |
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/app/v1/_lib/proxy/forwarder.ts
Line: 1226-1235
Comment:
**Multi-endpoint provider skips remaining endpoints on rectifier failover**
When `!hasRetryBudget` causes the inner loop to `break`, only the **first** endpoint of `currentProvider` has been tried. For providers configured with multiple endpoints (e.g. a vendor with a latency-sorted endpoint pool), the remaining endpoints for that provider are silently skipped.
This is appropriate in this case because the 400 error is a client-input issue (invalid thinking signature/budget) — trying a different endpoint on the same provider would not help. A brief comment here (and in the analogous budget-rectifier block at ~line 1382) would make this intent explicit for future readers, since elsewhere in the file "break" from the inner loop implies "all endpoints for this provider have been exhausted".
```suggestion
// NOTE: We intentionally skip remaining endpoints for this provider (if any).
// The 400 error is a client-input issue that would reproduce on every endpoint,
// so endpoint-level failover would not help.
lastRectifierSwitchError = lastError;
failedProviderIds.push(currentProvider.id);
break;
```
How can I resolve this? If you propose a fix, please make it concise.| doForward.mockImplementationOnce(async () => { | ||
| throw new ProxyError("invalid signature in thinking block", 400); | ||
| }); | ||
| doForward.mockResolvedValueOnce( |
There was a problem hiding this comment.
mockResolvedValue (persistent) instead of mockResolvedValueOnce
selectAlternative.mockResolvedValue(providerB) is a permanent/sticky mock — every call to selectAlternative will return providerB for the lifetime of the test and potentially beyond (until the spy is replaced or cleared). For a test that expects selectAlternative to be called exactly once (expect(selectAlternative).toHaveBeenCalledTimes(1)), using mockResolvedValueOnce would be more precise and defensive. If selectAlternative is called unexpectedly a second time (e.g. due to a logic error), the sticky mock silently returns providerB and the test may still pass, masking the bug.
The same applies to the analogous budget rectifier test at line ~448.
| doForward.mockResolvedValueOnce( | |
| selectAlternative.mockResolvedValueOnce(providerB); |
Prompt To Fix With AI
This is a comment left during a code review.
Path: tests/unit/proxy/proxy-forwarder-retry-limit.test.ts
Line: 374
Comment:
**`mockResolvedValue` (persistent) instead of `mockResolvedValueOnce`**
`selectAlternative.mockResolvedValue(providerB)` is a permanent/sticky mock — every call to `selectAlternative` will return `providerB` for the lifetime of the test and potentially beyond (until the spy is replaced or cleared). For a test that expects `selectAlternative` to be called exactly once (`expect(selectAlternative).toHaveBeenCalledTimes(1)`), using `mockResolvedValueOnce` would be more precise and defensive. If `selectAlternative` is called unexpectedly a second time (e.g. due to a logic error), the sticky mock silently returns `providerB` and the test may still pass, masking the bug.
The same applies to the analogous budget rectifier test at line ~448.
```suggestion
selectAlternative.mockResolvedValueOnce(providerB);
```
How can I resolve this? If you propose a fix, please make it concise.|
已按 CodeRabbit 的 CHANGES_REQUESTED 修复并补测:
同时补齐/加固单测:streaming hedge 场景 + maxRetryAttempts=2 连续失败边界;并将 selectAlternative mock 收敛为 mockResolvedValueOnce。 @coderabbitai review |
|
✅ Actions performedReview triggered.
|
There was a problem hiding this comment.
🧹 Nitpick comments (3)
src/app/v1/_lib/proxy/forwarder.ts (1)
1124-1151: 建议收敛 special settings 持久化逻辑,减少分支漂移。同一段持久化代码在同步与 streaming hedge 的 signature/budget 两条路径重复出现,后续改动容易漏改。建议统一复用
persistSpecialSettings(...)(在 hedge 分支传入主session),降低维护风险。Also applies to: 1280-1307, 3215-3242, 3334-3361
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/app/v1/_lib/proxy/forwarder.ts` around lines 1124 - 1151, Duplicate persistence logic for specialSettings appears in multiple paths (including the shown block using SessionManager.storeSessionSpecialSettings and updateMessageRequestDetails); extract and consolidate this into a single helper like persistSpecialSettings(sessionOrMainSession, messageRequestId?) and call it from all branches (for hedge streaming pass the main session as the first argument and provide messageRequestId when available) so both session persistence (SessionManager.storeSessionSpecialSettings) and message request persistence (updateMessageRequestDetails) are centralized; update all usages (including the other occurrences noted) to call the new persistSpecialSettings helper and remove the duplicated try/catch blocks.tests/unit/proxy/proxy-forwarder-retry-limit.test.ts (2)
126-129: 建议降低测试对生产内部常量导出的耦合。这里直接依赖
MAX_THINKING_BUDGET/MAX_TOKENS_VALUE会把测试需求反向施加到生产导出面。若这两个常量并非明确公共 API,建议改为通过行为断言或测试侧 fixture 常量来验证。🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@tests/unit/proxy/proxy-forwarder-retry-limit.test.ts` around lines 126 - 129, The test currently imports MAX_THINKING_BUDGET and MAX_TOKENS_VALUE from production code; instead remove that import and decouple the test by either using local fixture constants (define test-side constants like expectedThinkingBudget / expectedMaxTokens) or by asserting behavior (e.g., assert that the forwarder enforces the retry/token limits via observable outcomes rather than mirroring exported values). Update the test that references MAX_THINKING_BUDGET and MAX_TOKENS_VALUE to use these test fixtures or behavior assertions so the test no longer depends on those production exports.
328-407: 建议补充 no-budget 路径的审计字段断言。当前用例已覆盖“重试/切换”行为,但还缺少对审计语义的直接保护:在
maxRetryAttempts=1的切换路径中,建议断言retryAttemptNumber不存在(或为undefined),避免未来回归到“记录了不存在的同供应商重试”。Also applies to: 409-485, 487-583
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@tests/unit/proxy/proxy-forwarder-retry-limit.test.ts` around lines 328 - 407, 在 maxRetryAttempts=1 的切换路径测试 ("thinking signature rectifier: maxRetryAttempts=1 should switch provider...") 中补充对审计字段的断言:在调用 ProxyForwarder.send(session) 后,检查产生的审计条目(inspect session 的审计存储点,例如 session.auditEntries / session.request.audit... 或项目中实际存放审计记录的集合)并断言该“no-budget 切换”条目上不包含 retryAttemptNumber(即为 undefined 或不存在);在同样的其它两个测试(行 409-485, 487-583 区域)也加上相同断言以防回归,并将断言位置与现有对 provider chain / doForward 调用次数的断言一起放置以便可读性(参照 symbols: ProxyForwarder.send, session, doForward, selectAlternative)。
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Nitpick comments:
In `@src/app/v1/_lib/proxy/forwarder.ts`:
- Around line 1124-1151: Duplicate persistence logic for specialSettings appears
in multiple paths (including the shown block using
SessionManager.storeSessionSpecialSettings and updateMessageRequestDetails);
extract and consolidate this into a single helper like
persistSpecialSettings(sessionOrMainSession, messageRequestId?) and call it from
all branches (for hedge streaming pass the main session as the first argument
and provide messageRequestId when available) so both session persistence
(SessionManager.storeSessionSpecialSettings) and message request persistence
(updateMessageRequestDetails) are centralized; update all usages (including the
other occurrences noted) to call the new persistSpecialSettings helper and
remove the duplicated try/catch blocks.
In `@tests/unit/proxy/proxy-forwarder-retry-limit.test.ts`:
- Around line 126-129: The test currently imports MAX_THINKING_BUDGET and
MAX_TOKENS_VALUE from production code; instead remove that import and decouple
the test by either using local fixture constants (define test-side constants
like expectedThinkingBudget / expectedMaxTokens) or by asserting behavior (e.g.,
assert that the forwarder enforces the retry/token limits via observable
outcomes rather than mirroring exported values). Update the test that references
MAX_THINKING_BUDGET and MAX_TOKENS_VALUE to use these test fixtures or behavior
assertions so the test no longer depends on those production exports.
- Around line 328-407: 在 maxRetryAttempts=1 的切换路径测试 ("thinking signature
rectifier: maxRetryAttempts=1 should switch provider...") 中补充对审计字段的断言:在调用
ProxyForwarder.send(session) 后,检查产生的审计条目(inspect session 的审计存储点,例如
session.auditEntries / session.request.audit... 或项目中实际存放审计记录的集合)并断言该“no-budget
切换”条目上不包含 retryAttemptNumber(即为 undefined 或不存在);在同样的其它两个测试(行 409-485, 487-583
区域)也加上相同断言以防回归,并将断言位置与现有对 provider chain / doForward 调用次数的断言一起放置以便可读性(参照
symbols: ProxyForwarder.send, session, doForward, selectAlternative)。
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: 395e3013-daf3-47d2-83ff-ea13300cd6bd
📒 Files selected for processing (4)
src/app/v1/_lib/proxy/forwarder.tssrc/lib/utils/special-settings.tssrc/types/special-settings.tstests/unit/proxy/proxy-forwarder-retry-limit.test.ts
| describe("ProxyForwarder - maxRetryAttempts should not be bypassed by thinking rectifiers", () => { | ||
| beforeEach(() => { | ||
| vi.clearAllMocks(); | ||
| }); | ||
|
|
||
| test("thinking signature rectifier: maxRetryAttempts=1 should switch provider (not retry same provider)", async () => { | ||
| vi.useFakeTimers(); | ||
|
|
There was a problem hiding this comment.
No test coverage for the new streamingHedge rectifier logic
The new describe block "ProxyForwarder - maxRetryAttempts should not be bypassed by thinking rectifiers" exclusively calls ProxyForwarder.send(). However, this PR also introduces ~250 lines of new rectifier handling inside streamingHedge (lines ~3178–3415 of forwarder.ts) that is completely untested.
The streaming-hedge path differs significantly from the send() path:
- It uses
signatureRectifierRetriedProviderIds/budgetRectifierRetriedProviderIdsSets (vs thebooleanflags insend()) - It calls
launchAlternative()+finishIfExhausted()for the!hasRetryBudgetcase instead of thelastRectifierSwitchErrormechanism - It relies on
attempt.providerAttemptNumbertracked throughproviderAttemptCounts
Consider adding parallel test cases that exercise streamingHedge for at least:
maxRetryAttempts=1→ should switch provider, not retry samemaxRetryAttempts=1+ no alternative → should settle with original 400 (viaresolveHedgeTerminalError)maxRetryAttempts=2→ should retry same provider
Prompt To Fix With AI
This is a comment left during a code review.
Path: tests/unit/proxy/proxy-forwarder-retry-limit.test.ts
Line: 323-330
Comment:
**No test coverage for the new `streamingHedge` rectifier logic**
The new describe block `"ProxyForwarder - maxRetryAttempts should not be bypassed by thinking rectifiers"` exclusively calls `ProxyForwarder.send()`. However, this PR also introduces ~250 lines of new rectifier handling inside `streamingHedge` (lines ~3178–3415 of `forwarder.ts`) that is **completely untested**.
The streaming-hedge path differs significantly from the `send()` path:
- It uses `signatureRectifierRetriedProviderIds` / `budgetRectifierRetriedProviderIds` Sets (vs the `boolean` flags in `send()`)
- It calls `launchAlternative()` + `finishIfExhausted()` for the `!hasRetryBudget` case instead of the `lastRectifierSwitchError` mechanism
- It relies on `attempt.providerAttemptNumber` tracked through `providerAttemptCounts`
Consider adding parallel test cases that exercise `streamingHedge` for at least:
- `maxRetryAttempts=1` → should switch provider, not retry same
- `maxRetryAttempts=1` + no alternative → should settle with original 400 (via `resolveHedgeTerminalError`)
- `maxRetryAttempts=2` → should retry same provider
How can I resolve this? If you propose a fix, please make it concise.| // Align streaming-hedge behavior with send(): rectify specific Anthropic 400s and then retry/failover, | ||
| // while strictly respecting provider-level maxRetryAttempts. | ||
| const signatureTrigger = detectThinkingSignatureRectifierTrigger(errorMessage); | ||
| if (signatureTrigger) { | ||
| const settings = await getCachedSystemSettings(); | ||
| const enabled = settings.enableThinkingSignatureRectifier ?? true; | ||
|
|
||
| if (enabled) { | ||
| // This is a client input / request-shape issue, and must not be counted into circuit breakers. | ||
| // Also, keep NON_RETRYABLE_CLIENT_ERROR so that if no alternative provider exists, | ||
| // resolveHedgeTerminalError() returns the original error instead of a generic 503. | ||
| errorCategory = ErrorCategory.NON_RETRYABLE_CLIENT_ERROR; | ||
| lastErrorCategory = errorCategory; | ||
|
|
||
| if (!signatureRectifierRetriedProviderIds.has(attempt.provider.id)) { | ||
| const requestDetailsBeforeRectify = buildRequestDetails(session); | ||
| const rectified = rectifyAnthropicRequestMessage( | ||
| session.request.message as Record<string, unknown> | ||
| ); | ||
| const hasRetryBudget = attempt.providerAttemptNumber < attempt.maxAttemptsPerProvider; | ||
|
|
||
| session.addSpecialSetting({ | ||
| type: "thinking_signature_rectifier", | ||
| scope: "request", | ||
| hit: rectified.applied, | ||
| providerId: attempt.provider.id, | ||
| providerName: attempt.provider.name, | ||
| trigger: signatureTrigger, | ||
| attemptNumber: attempt.providerAttemptNumber, | ||
| ...(hasRetryBudget | ||
| ? { retryAttemptNumber: attempt.providerAttemptNumber + 1 } | ||
| : {}), | ||
| removedThinkingBlocks: rectified.removedThinkingBlocks, | ||
| removedRedactedThinkingBlocks: rectified.removedRedactedThinkingBlocks, | ||
| removedSignatureFields: rectified.removedSignatureFields, | ||
| }); | ||
|
|
||
| const specialSettings = session.getSpecialSettings(); | ||
| if (specialSettings && session.sessionId) { | ||
| try { | ||
| await SessionManager.storeSessionSpecialSettings( | ||
| session.sessionId, | ||
| specialSettings, |
There was a problem hiding this comment.
~200-line duplication between signature and budget rectifier blocks in streamingHedge
The signature rectifier block (lines ~3178–3300) and the budget rectifier block (lines ~3305–3415) are structurally nearly identical — both:
- Check
isAnthropicProvider - Detect trigger
- Fetch and check settings enable flag
- Set
errorCategory = NON_RETRYABLE_CLIENT_ERROR - Guard with
!<rectifierRetriedProviderIds>.has(attempt.provider.id) - Rectify the message in-place
- Compute
hasRetryBudget - Add special setting, persist to session/DB
- Conditionally add to provider chain with
reason: "retry_failed" - Branch on
hasRetryBudget: retry vs.launchAlternative+finishIfExhausted
The only differences are the detector function, the rectifier function, the special-setting type, and the set tracking variable. This duplication is ~200 lines and carries a high maintenance risk — a future fix to one block (e.g. correcting the reason field, updating persistence logic) is easy to miss in the other.
Consider extracting a shared helper:
async function handleRectifier(
attempt: StreamingHedgeAttempt,
trigger: RectifierTrigger,
rectify: (msg: Record<string, unknown>) => RectifiedResult,
retriedSet: Set<number>,
settingType: "thinking_signature_rectifier" | "thinking_budget_rectifier"
): Promise<boolean /* handled */> { ... }Prompt To Fix With AI
This is a comment left during a code review.
Path: src/app/v1/_lib/proxy/forwarder.ts
Line: 3178-3220
Comment:
**~200-line duplication between signature and budget rectifier blocks in `streamingHedge`**
The signature rectifier block (lines ~3178–3300) and the budget rectifier block (lines ~3305–3415) are structurally nearly identical — both:
1. Check `isAnthropicProvider`
2. Detect trigger
3. Fetch and check settings enable flag
4. Set `errorCategory = NON_RETRYABLE_CLIENT_ERROR`
5. Guard with `!<rectifierRetriedProviderIds>.has(attempt.provider.id)`
6. Rectify the message in-place
7. Compute `hasRetryBudget`
8. Add special setting, persist to session/DB
9. Conditionally add to provider chain with `reason: "retry_failed"`
10. Branch on `hasRetryBudget`: retry vs. `launchAlternative` + `finishIfExhausted`
The only differences are the detector function, the rectifier function, the special-setting type, and the set tracking variable. This duplication is ~200 lines and carries a high maintenance risk — a future fix to one block (e.g. correcting the `reason` field, updating persistence logic) is easy to miss in the other.
Consider extracting a shared helper:
```typescript
async function handleRectifier(
attempt: StreamingHedgeAttempt,
trigger: RectifierTrigger,
rectify: (msg: Record<string, unknown>) => RectifiedResult,
retriedSet: Set<number>,
settingType: "thinking_signature_rectifier" | "thinking_budget_rectifier"
): Promise<boolean /* handled */> { ... }
```
How can I resolve this? If you propose a fix, please make it concise.| return; | ||
| } | ||
|
|
||
| const isAnthropicProvider = | ||
| attempt.provider.providerType === "claude" || | ||
| attempt.provider.providerType === "claude-auth"; | ||
| if (isAnthropicProvider) { | ||
| // Align streaming-hedge behavior with send(): rectify specific Anthropic 400s and then retry/failover, |
There was a problem hiding this comment.
Rectifier failover labels alternative provider success as hedge_winner
When a rectifier's !hasRetryBudget path calls launchAlternative(), the alternative provider is added to startedProviderIds. When that alternative succeeds in settleSuccess, uniqueProvidersAttempted = startedProviderIds.size = 2, so isActualHedgeWin = true and reason = "hedge_winner" is written to the audit chain.
However, this is a rectifier-triggered failover — provider A was exhausted due to a client input error (400), not a timed race. Labeling the result "hedge_winner" implies a simultaneous hedge race occurred, which can mislead operators scanning audit logs. In a real simultaneous hedge (e.g., latency racing), both providers would have been launched at the start; here B is launched only after A's budget is exhausted.
Consider extending the reason enum with a dedicated value (e.g., "rectifier_failover_success") to distinguish this scenario, or at minimum document this semantic overlap in a comment adjacent to the isActualHedgeWin check:
// NOTE: isActualHedgeWin is also true for rectifier-triggered failovers where
// launchAlternative() was called from the !hasRetryBudget path. This is by design:
// the semantics of "multiple providers participated" is preserved, even though
// no simultaneous hedge race occurred.
const isActualHedgeWin = uniqueProvidersAttempted > 1;Prompt To Fix With AI
This is a comment left during a code review.
Path: src/app/v1/_lib/proxy/forwarder.ts
Line: 3171-3178
Comment:
**Rectifier failover labels alternative provider success as `hedge_winner`**
When a rectifier's `!hasRetryBudget` path calls `launchAlternative()`, the alternative provider is added to `startedProviderIds`. When that alternative succeeds in `settleSuccess`, `uniqueProvidersAttempted = startedProviderIds.size = 2`, so `isActualHedgeWin = true` and `reason = "hedge_winner"` is written to the audit chain.
However, this is a **rectifier-triggered failover** — provider A was exhausted due to a client input error (400), not a timed race. Labeling the result `"hedge_winner"` implies a simultaneous hedge race occurred, which can mislead operators scanning audit logs. In a real simultaneous hedge (e.g., latency racing), both providers would have been launched at the start; here B is launched only after A's budget is exhausted.
Consider extending the `reason` enum with a dedicated value (e.g., `"rectifier_failover_success"`) to distinguish this scenario, or at minimum document this semantic overlap in a comment adjacent to the `isActualHedgeWin` check:
```typescript
// NOTE: isActualHedgeWin is also true for rectifier-triggered failovers where
// launchAlternative() was called from the !hasRetryBudget path. This is by design:
// the semantics of "multiple providers participated" is preserved, even though
// no simultaneous hedge race occurred.
const isActualHedgeWin = uniqueProvidersAttempted > 1;
```
How can I resolve this? If you propose a fix, please make it concise.Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!
| const uniqueProvidersAttempted = startedProviderIds.size; | ||
| const didInitialProviderStart = startedProviderIds.has(initialProvider.id); | ||
| const isHedgeWin = hedgeTriggered && uniqueProvidersAttempted > 1; | ||
| const isRetrySuccess = | ||
| !isHedgeWin && (uniqueProvidersAttempted > 1 || attempt.providerAttemptNumber > 1); |
There was a problem hiding this comment.
totalProvidersAttempted semantic change breaks audit log accuracy for retries
uniqueProvidersAttempted uses startedProviderIds.size, which only counts unique providers. Before this PR, launchedProviderCount counted every individual attempt (including same-provider rectifier retries). Now, if provider A is tried twice (initial + rectifier retry) and then provider B succeeds, totalProvidersAttempted reports 2 (A and B) instead of 3 (3 total launches).
This affects both the success audit log at the bottom of settleSuccess and any downstream tooling that uses totalProvidersAttempted to infer how many requests were made upstream. The field's name implies a count of providers, but its historical meaning was "total attempts". Since the PR adds same-provider rectifier retries to streamingHedge for the first time, this silent semantic change could confuse operators comparing old and new logs.
Consider renaming the field in the log/audit to uniqueProvidersAttempted or adding a separate totalAttemptCount: launchedProviderCount field to keep both pieces of information:
const uniqueProvidersAttempted = startedProviderIds.size;
// ...
logger.info("...", {
// ...
totalProvidersAttempted: uniqueProvidersAttempted, // unique providers
totalAttemptCount: launchedProviderCount, // total requests fired
});Prompt To Fix With AI
This is a comment left during a code review.
Path: src/app/v1/_lib/proxy/forwarder.ts
Line: 3361-3365
Comment:
**`totalProvidersAttempted` semantic change breaks audit log accuracy for retries**
`uniqueProvidersAttempted` uses `startedProviderIds.size`, which only counts *unique* providers. Before this PR, `launchedProviderCount` counted every individual attempt (including same-provider rectifier retries). Now, if provider A is tried twice (initial + rectifier retry) and then provider B succeeds, `totalProvidersAttempted` reports `2` (A and B) instead of `3` (3 total launches).
This affects both the success audit log at the bottom of `settleSuccess` and any downstream tooling that uses `totalProvidersAttempted` to infer how many requests were made upstream. The field's name implies a count of providers, but its historical meaning was "total attempts". Since the PR adds same-provider rectifier retries to `streamingHedge` for the first time, this silent semantic change could confuse operators comparing old and new logs.
Consider renaming the field in the log/audit to `uniqueProvidersAttempted` or adding a separate `totalAttemptCount: launchedProviderCount` field to keep both pieces of information:</p>
```typescript
const uniqueProvidersAttempted = startedProviderIds.size;
// ...
logger.info("...", {
// ...
totalProvidersAttempted: uniqueProvidersAttempted, // unique providers
totalAttemptCount: launchedProviderCount, // total requests fired
});
```
How can I resolve this? If you propose a fix, please make it concise.| return; | ||
| } | ||
|
|
||
| const isAnthropicProvider = | ||
| attempt.provider.providerType === "claude" || | ||
| attempt.provider.providerType === "claude-auth"; | ||
| if (isAnthropicProvider) { |
There was a problem hiding this comment.
Signature rectifier fires for ALL failed attempts of the same provider in streamingHedge
The guard !signatureRectifierRetriedProviderIds.has(attempt.provider.id) correctly prevents infinite loops — once rectification is attempted for a provider, subsequent failures from the same provider skip the rectifier. However, there is a window for a race condition in the streaming hedge context:
If the initial provider (A) triggers the rectifier and the system calls launchAlternative(), provider B is launched concurrently. If provider B also fails with a thinking signature error, it will enter this block because signatureRectifierRetriedProviderIds tracks provider IDs separately per provider. That's fine in isolation, but rectifyAnthropicRequestMessage mutates session.request.message in-place. If both provider A's rectifier call and provider B's rectifier call execute concurrently (or in rapid succession), the second rectification runs on an already-rectified message body.
For the !hasRetryBudget path this isn't a concern because return is called after launchAlternative(). But for the hasRetryBudget path (startAttempt + return), two concurrent retries could both attempt to rectify the same message object.
Consider recording the rectification as already applied to the session before launching the retry, or making rectifyAnthropicRequestMessage idempotent (checking if fields were already removed).
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/app/v1/_lib/proxy/forwarder.ts
Line: 3118-3124
Comment:
**Signature rectifier fires for ALL failed attempts of the same provider in `streamingHedge`**
The guard `!signatureRectifierRetriedProviderIds.has(attempt.provider.id)` correctly prevents infinite loops — once rectification is attempted for a provider, subsequent failures from the same provider skip the rectifier. However, there is a window for a race condition in the streaming hedge context:
If the initial provider (A) triggers the rectifier and the system calls `launchAlternative()`, provider B is launched concurrently. If provider B *also* fails with a thinking signature error, it will enter this block because `signatureRectifierRetriedProviderIds` tracks provider IDs separately per provider. That's fine in isolation, but `rectifyAnthropicRequestMessage` mutates `session.request.message` in-place. If both provider A's rectifier call and provider B's rectifier call execute concurrently (or in rapid succession), the second rectification runs on an already-rectified message body.
For the **`!hasRetryBudget` path** this isn't a concern because `return` is called after `launchAlternative()`. But for the **`hasRetryBudget` path** (`startAttempt` + `return`), two concurrent retries could both attempt to rectify the same message object.
Consider recording the rectification as already applied to the session *before* launching the retry, or making `rectifyAnthropicRequestMessage` idempotent (checking if fields were already removed).
How can I resolve this? If you propose a fix, please make it concise.|
本轮跟进主要处理 Greptile/AI review 的两个点并补齐覆盖:
另外做了两个小整理:
验证:
|
- 先挂载 rejects 断言再推进 fake timers,避免 Vitest 记录未处理的 Promise 拒绝 - 移除冗余 doForward 默认 mockImplementation,提升可读性 - 修正 forwarder.ts 中对 retry_failed 语义的误导性注释
- 用 rejects 断言替代手动 catch 变量,避免 promise microtask 时序导致潜在 flake - 将 lastRectifierSwitchError 限定在单次 provider 尝试作用域,并补充为何跳过剩余端点的注释
- retryAttemptNumber 在无同供应商重试预算时缺省,避免误导审计\n\n- streaming hedge 失败路径补齐 thinking signature/budget rectifier,并严格遵守 maxRetryAttempts(可重试则同 provider retry,不可则 failover)\n\n- 补齐单测:stream=true hedge 场景、maxRetryAttempts=2 连续失败边界;并收敛 selectAlternative mock 为 mockResolvedValueOnce
- 在 maxRetryAttempts=1 的 provider failover / 无替代供应商用例中断言 retryAttemptNumber 为 undefined,防止审计字段回归误记
e3862e5 to
b45a340
Compare
|
本轮补齐了 Greptile 提到的 streaming hedge 并发 rectifier 风险,并用单测保护:
本地验证: |
| if (signatureTrigger) { | ||
| const settings = await getCachedSystemSettings(); | ||
| const enabled = settings.enableThinkingSignatureRectifier ?? true; | ||
|
|
||
| if (enabled) { | ||
| // This is a client input / request-shape issue, and must not be counted into circuit breakers. | ||
| // Also, keep NON_RETRYABLE_CLIENT_ERROR so that if no alternative provider exists, | ||
| // resolveHedgeTerminalError() returns the original error instead of a generic 503. | ||
| errorCategory = ErrorCategory.NON_RETRYABLE_CLIENT_ERROR; | ||
| lastErrorCategory = errorCategory; | ||
|
|
||
| const handled = await withRectifierLock(async () => { | ||
| if (settled || winnerCommitted) return false; | ||
|
|
||
| // If request has been rectified since this attempt started, treat its error as stale. | ||
| // Do not terminate early based on an outdated request snapshot. | ||
| if (attempt.requestVersion < requestVersion) { | ||
| const hasProviderInFlight = Array.from(attempts).some( | ||
| (other) => | ||
| other.provider.id === attempt.provider.id && | ||
| other.requestVersion === requestVersion && | ||
| !other.settled | ||
| ); | ||
| if (hasProviderInFlight) { |
There was a problem hiding this comment.
Always-true if (!hasProviderInFlight) dead branch in stale-path
After if (hasProviderInFlight) { return true; }, every subsequent line is only reachable when hasProviderInFlight is false. The if (!hasProviderInFlight) { ... } wrapper is therefore always entered — it is a dead condition. This means the launchAlternative() / finishIfExhausted() calls below are silently only reachable when !hasRetryBudget, which isn't obvious to readers scanning the code.
The same structural issue appears identically in the budget rectifier stale-check path a little further down.
Consider flattening:
// hasProviderInFlight === false here (we returned true above if it was)
const currentAttemptNumber =
providerAttemptCounts.get(attempt.provider.id) ?? attempt.providerAttemptNumber;
const hasRetryBudget = currentAttemptNumber < attempt.maxAttemptsPerProvider;
session.addProviderToChain(attempt.provider, { ... });
if (hasRetryBudget) {
await startAttempt(attempt.provider, attempt.provider.id === initialProvider.id, {
allowDuplicateProvider: true,
});
return true;
}
// !hasRetryBudget → fall through to launchAlternative
await launchAlternative();
await finishIfExhausted();
return true;This makes it explicit that launchAlternative() is reached only when !hasRetryBudget.
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/app/v1/_lib/proxy/forwarder.ts
Line: 3162-3185
Comment:
**Always-true `if (!hasProviderInFlight)` dead branch in stale-path**
After `if (hasProviderInFlight) { return true; }`, every subsequent line is only reachable when `hasProviderInFlight` is `false`. The `if (!hasProviderInFlight) { ... }` wrapper is therefore always entered — it is a dead condition. This means the `launchAlternative()` / `finishIfExhausted()` calls below are silently only reachable when `!hasRetryBudget`, which isn't obvious to readers scanning the code.
The same structural issue appears identically in the budget rectifier stale-check path a little further down.
Consider flattening:
```typescript
// hasProviderInFlight === false here (we returned true above if it was)
const currentAttemptNumber =
providerAttemptCounts.get(attempt.provider.id) ?? attempt.providerAttemptNumber;
const hasRetryBudget = currentAttemptNumber < attempt.maxAttemptsPerProvider;
session.addProviderToChain(attempt.provider, { ... });
if (hasRetryBudget) {
await startAttempt(attempt.provider, attempt.provider.id === initialProvider.id, {
allowDuplicateProvider: true,
});
return true;
}
// !hasRetryBudget → fall through to launchAlternative
await launchAlternative();
await finishIfExhausted();
return true;
```
This makes it explicit that `launchAlternative()` is reached only when `!hasRetryBudget`.
How can I resolve this? If you propose a fix, please make it concise.| @@ -3188,12 +3522,12 @@ export class ProxyForwarder { | |||
| session.sessionId!, | |||
There was a problem hiding this comment.
"retry_success" used for cross-provider failover success when hedgeTriggered = false
When a rectifier's !hasRetryBudget path calls launchAlternative() and the alternative provider succeeds without a prior hedge threshold trigger (hedgeTriggered = false), isHedgeWin = false and isRetrySuccess = !isHedgeWin && (uniqueProvidersAttempted > 1) evaluates to true. The chain records reason: "retry_success".
However, isFailoverSuccess is simultaneously true (different provider from initial) and is separately logged via setSuccessAuditValues. This creates an inconsistency between the chain reason field ("retry_success") and the audit value (isFailoverSuccess: true) for what is semantically a provider-switch event, not a same-provider retry.
Operators parsing the chain looking for failover events would use reason, but here the same event would appear as "retry_success" rather than any failover-specific label. The previously flagged hedge_winner issue (prior thread) has been corrected, but a dedicated reason like "rectifier_failover_success" (or reusing "failover_success") would eliminate the remaining ambiguity, since isFailoverSuccess alone is not surfaced in the chain.
reason: isHedgeWin
? "hedge_winner"
: isFailoverSuccess
? "failover_success" // distinguish cross-provider failover from same-provider retry
: isRetrySuccess
? "retry_success"
: "request_success",Prompt To Fix With AI
This is a comment left during a code review.
Path: src/app/v1/_lib/proxy/forwarder.ts
Line: 3498-3522
Comment:
**`"retry_success"` used for cross-provider failover success when `hedgeTriggered = false`**
When a rectifier's `!hasRetryBudget` path calls `launchAlternative()` and the alternative provider succeeds without a prior hedge threshold trigger (`hedgeTriggered = false`), `isHedgeWin = false` and `isRetrySuccess = !isHedgeWin && (uniqueProvidersAttempted > 1)` evaluates to `true`. The chain records `reason: "retry_success"`.
However, `isFailoverSuccess` is simultaneously `true` (different provider from initial) and is separately logged via `setSuccessAuditValues`. This creates an inconsistency between the chain `reason` field (`"retry_success"`) and the audit value (`isFailoverSuccess: true`) for what is semantically a provider-switch event, not a same-provider retry.
Operators parsing the chain looking for failover events would use `reason`, but here the same event would appear as `"retry_success"` rather than any failover-specific label. The previously flagged `hedge_winner` issue (prior thread) has been corrected, but a dedicated reason like `"rectifier_failover_success"` (or reusing `"failover_success"`) would eliminate the remaining ambiguity, since `isFailoverSuccess` alone is not surfaced in the chain.
```typescript
reason: isHedgeWin
? "hedge_winner"
: isFailoverSuccess
? "failover_success" // distinguish cross-provider failover from same-provider retry
: isRetrySuccess
? "retry_success"
: "request_success",
```
How can I resolve this? If you propose a fix, please make it concise.|
本轮跟进仅做 1 个等价整理,回应 Greptile inline comment(不改语义,仅提可读性):
验证:
备注: |
| return; | ||
| } | ||
|
|
||
| const isAnthropicProvider = | ||
| attempt.provider.providerType === "claude" || | ||
| attempt.provider.providerType === "claude-auth"; | ||
| if (isAnthropicProvider) { | ||
| // Align streaming-hedge behavior with send(): rectify specific Anthropic 400s and then retry/failover, | ||
| // while strictly respecting provider-level maxRetryAttempts. | ||
| const signatureTrigger = detectThinkingSignatureRectifierTrigger(errorMessage); | ||
| if (signatureTrigger) { | ||
| const settings = await getCachedSystemSettings(); | ||
| const enabled = settings.enableThinkingSignatureRectifier ?? true; | ||
|
|
There was a problem hiding this comment.
Stale attempt with in-flight sibling silently drops from audit chain
When hasProviderInFlight is true, the function returns true immediately without adding a addProviderToChain entry for the stale failure. Every other failure exit from the rectifier block (the !hasProviderInFlight stale path and the fresh path) calls session.addProviderToChain(...) before proceeding. Silently swallowing this failure means concurrent streaming-hedge failures during rectifier handling are invisible to audit logs — operators will see fewer chain entries than actual upstream requests were made.
Consider adding a chain entry before the early return:
if (hasProviderInFlight) {
// Record the stale failure so the audit chain reflects every request made.
session.addProviderToChain(attempt.provider, {
...attempt.endpointAudit,
reason: "retry_failed",
attemptNumber: attempt.sequence,
statusCode,
errorMessage,
circuitState: getCircuitState(attempt.provider.id),
});
return true;
}Prompt To Fix With AI
This is a comment left during a code review.
Path: src/app/v1/_lib/proxy/forwarder.ts
Line: 3152-3165
Comment:
**Stale attempt with in-flight sibling silently drops from audit chain**
When `hasProviderInFlight` is `true`, the function returns `true` immediately without adding a `addProviderToChain` entry for the stale failure. Every other failure exit from the rectifier block (the `!hasProviderInFlight` stale path and the fresh path) calls `session.addProviderToChain(...)` before proceeding. Silently swallowing this failure means concurrent streaming-hedge failures during rectifier handling are invisible to audit logs — operators will see fewer chain entries than actual upstream requests were made.
Consider adding a chain entry before the early return:
```typescript
if (hasProviderInFlight) {
// Record the stale failure so the audit chain reflects every request made.
session.addProviderToChain(attempt.provider, {
...attempt.endpointAudit,
reason: "retry_failed",
attemptNumber: attempt.sequence,
statusCode,
errorMessage,
circuitState: getCircuitState(attempt.provider.id),
});
return true;
}
```
How can I resolve this? If you propose a fix, please make it concise.| @@ -3259,6 +3606,7 @@ export class ProxyForwarder { | |||
| return; | |||
| } | |||
There was a problem hiding this comment.
providerAttemptCounts incremented before endpoint validation succeeds
providerAttemptCounts.set(provider.id, providerAttemptNumber) is executed synchronously before the async endpoint selection. If endpointSelection is null (no endpoints available), the function exits early — but the count is already bumped. In subsequent stale-path checks for the same provider:
const currentAttemptNumber =
providerAttemptCounts.get(attempt.provider.id) ?? attempt.providerAttemptNumber;
const hasRetryBudget = currentAttemptNumber < attempt.maxAttemptsPerProvider;currentAttemptNumber will reflect the phantom increment, making hasRetryBudget appear exhausted one step earlier than it truly is. Worse, if hasRetryBudget is still true, the stale path calls startAttempt again, which increments the count a second time and hits endpoint selection failure once more — producing a redundant launchAlternative() chain.
Move the providerAttemptCounts.set call to after a successful endpoint resolution (alongside startedProviderIds.add):
// After: if (!endpointSelection) { ... return; }
startedProviderIds.add(provider.id);
providerAttemptCounts.set(provider.id, providerAttemptNumber);
launchedProviderCount += 1;Prompt To Fix With AI
This is a comment left during a code review.
Path: src/app/v1/_lib/proxy/forwarder.ts
Line: 3585-3607
Comment:
**`providerAttemptCounts` incremented before endpoint validation succeeds**
`providerAttemptCounts.set(provider.id, providerAttemptNumber)` is executed synchronously before the async endpoint selection. If `endpointSelection` is `null` (no endpoints available), the function exits early — but the count is already bumped. In subsequent stale-path checks for the same provider:
```typescript
const currentAttemptNumber =
providerAttemptCounts.get(attempt.provider.id) ?? attempt.providerAttemptNumber;
const hasRetryBudget = currentAttemptNumber < attempt.maxAttemptsPerProvider;
```
`currentAttemptNumber` will reflect the phantom increment, making `hasRetryBudget` appear exhausted one step earlier than it truly is. Worse, if `hasRetryBudget` is still `true`, the stale path calls `startAttempt` again, which increments the count a second time and hits endpoint selection failure once more — producing a redundant `launchAlternative()` chain.
Move the `providerAttemptCounts.set` call to after a successful endpoint resolution (alongside `startedProviderIds.add`):
```typescript
// After: if (!endpointSelection) { ... return; }
startedProviderIds.add(provider.id);
providerAttemptCounts.set(provider.id, providerAttemptNumber);
launchedProviderCount += 1;
```
How can I resolve this? If you propose a fix, please make it concise.| circuitState: getCircuitState(attempt.provider.id), | ||
| }); | ||
|
|
||
| if (hasRetryBudget) { | ||
| await startAttempt(attempt.provider, attempt.provider.id === initialProvider.id, { | ||
| allowDuplicateProvider: true, | ||
| }); | ||
| return true; | ||
| } | ||
|
|
||
| await launchAlternative(); | ||
| await finishIfExhausted(); | ||
| return true; | ||
| } | ||
|
|
||
| if (signatureRectifierRetriedProviderIds.has(attempt.provider.id)) { | ||
| return false; |
There was a problem hiding this comment.
Stale retry path does not mark provider in signatureRectifierRetriedProviderIds
When the stale path decides hasRetryBudget = true and calls startAttempt, it never adds attempt.provider.id to signatureRectifierRetriedProviderIds. If that stale retry subsequently fails with another signature error, its requestVersion will equal the current requestVersion (fresh path), the guard signatureRectifierRetriedProviderIds.has(attempt.provider.id) returns false, and the code calls rectifyAnthropicRequestMessage a second time. Since the message is already clean, rectified.applied = false and the handler returns false. This causes the failure to fall through to the normal error-handling path, which calls launchAlternative() again.
While not catastrophic (idempotent rectifier + NON_RETRYABLE_CLIENT_ERROR prevents circuit-breaker writes), it produces an extraneous audit entry with hit: false and an unnecessary selectAlternative call.
Add the provider to the set before launching the stale retry:
if (hasRetryBudget) {
signatureRectifierRetriedProviderIds.add(attempt.provider.id);
await startAttempt(attempt.provider, attempt.provider.id === initialProvider.id, {
allowDuplicateProvider: true,
});
return true;
}The same pattern applies to the budget rectifier stale path.
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/app/v1/_lib/proxy/forwarder.ts
Line: 3199-3215
Comment:
**Stale retry path does not mark provider in `signatureRectifierRetriedProviderIds`**
When the stale path decides `hasRetryBudget = true` and calls `startAttempt`, it never adds `attempt.provider.id` to `signatureRectifierRetriedProviderIds`. If that stale retry subsequently fails with another signature error, its `requestVersion` will equal the current `requestVersion` (fresh path), the guard `signatureRectifierRetriedProviderIds.has(attempt.provider.id)` returns `false`, and the code calls `rectifyAnthropicRequestMessage` a second time. Since the message is already clean, `rectified.applied = false` and the handler returns `false`. This causes the failure to fall through to the normal error-handling path, which calls `launchAlternative()` again.
While not catastrophic (idempotent rectifier + `NON_RETRYABLE_CLIENT_ERROR` prevents circuit-breaker writes), it produces an extraneous audit entry with `hit: false` and an unnecessary `selectAlternative` call.
Add the provider to the set before launching the stale retry:
```typescript
if (hasRetryBudget) {
signatureRectifierRetriedProviderIds.add(attempt.provider.id);
await startAttempt(attempt.provider, attempt.provider.id === initialProvider.id, {
allowDuplicateProvider: true,
});
return true;
}
```
The same pattern applies to the budget rectifier stale path.
How can I resolve this? If you propose a fix, please make it concise.|
整流器本来就不应该算在重试次数里,因此此 PR 可能违反了设计预期。 |
Summary
Fix thinking signature and thinking budget rectifiers to respect the provider-level maxRetryAttempts configuration. Previously, these rectifiers would bypass maxRetryAttempts by using Math.max() to force an additional retry, making configurations like maxRetryAttempts=1 ineffective.
Problem
Single-provider max retry attempts (providers.max_retry_attempts) can be bypassed in thinking signature / thinking budget rectifier scenarios: even when configured to 1, an additional request to the same provider would still be made.
Related Issues:
Root Cause
ProxyForwarder rectifier branch was forcibly expanding maxAttemptsPerProvider using Math.max(), causing configured lower values to have no effect.
Solution
Changes
Core Changes
Supporting Changes
Testing
Automated Tests
Checklist
Description enhanced by Claude AI
Greptile Summary
This PR fixes the
Math.max()bypass in the thinking-signature and thinking-budget rectifiers — previously, even amaxRetryAttempts=1config would silently get an extra same-provider attempt. The fix introduces ahasRetryBudgetcheck that either retries the same provider (if budget permits) or breaks to provider failover, throwing the original 400 error when no alternative exists. A parallel set of changes adds the same rectifier logic to thestreamingHedgepath, including awithRectifierLockmutex,providerAttemptCountstracking, and stale-request version detection.Key changes:
forwarder.ts(send): ReplacedMath.max(maxAttemptsPerProvider, attemptCount + 1)withhasRetryBudgetguard; introducedlastRectifierSwitchErrorto propagate the original 400 instead of a 503 when no alternative provider exists.forwarder.ts(streamingHedge): Added ~300 lines of new rectifier handling mirroring thesend()fix, with awithRectifierLockmutex to prevent concurrent rectification,providerAttemptCountsfor per-provider attempt tracking, and stale-request-version detection.special-settings.ts/types/special-settings.ts:retryAttemptNumberis now optional;buildSettingKeyupdated accordingly.send()andstreamingHedge, including same-provider retry, provider switch, no-alternative 400 throw, and concurrent stale-attempt scenarios.Issues found:
streamingHedge'sstartAttempt,providerAttemptCountsis incremented before the async endpoint selection. If endpoint selection returnsnull, the phantom increment inflates subsequent stale-pathhasRetryBudgetchecks and can trigger redundantlaunchAlternative()calls.hasProviderInFlight = true, the function returnstruewithout callingsession.addProviderToChain(...). Every other error-exit path from the rectifier block records a chain entry; this omission means concurrent hedge failures during rectifier handling are invisible in audit logs.hasProviderInFlight = false,hasRetryBudget = true) starts a new attempt without adding the provider tosignatureRectifierRetriedProviderIds/budgetRectifierRetriedProviderIds. If that retry also fails with a rectifier-triggerable error, the guard is bypassed, causing a redundantrectifyAnthropicRequestMessagecall (applied=false), an extraneoushit: falseaudit entry, and an additional unnecessaryselectAlternativecall.Confidence Score: 2/5
send()path fix is solid and well-tested. However, the newstreamingHedgerectifier implementation (~300 lines) has three issues:providerAttemptCountsbeing incremented before endpoint validation can cause inflated budget checks and redundant failovers; stale-concurrent failures are silently dropped from the provider chain audit; and the stale retry path skips updating the rectifier-retried Sets, creating a guard bypass on subsequent failures. The PR also self-declares it is incomplete.Important Files Changed
Math.max()bypass with properhasRetryBudgetchecks; also adds ~300 lines of new rectifier handling tostreamingHedge(including a mutex,providerAttemptCounts, stale-request detection). Several issues found:providerAttemptCountsincremented before endpoint validation, stale in-flight sibling failures dropped from audit chain, and stale retry path not marking providers in the rectifier-retried Sets.send()andstreamingHedgerectifier paths including maxRetryAttempts=1 switch, no-alternative 400 throw, maxRetryAttempts=2 same-provider retry, and concurrent stale-attempt scenarios.retryAttemptNumberoptional on both rectifier setting types to accurately reflect that no retry is planned when the budget is exhausted; well-documented with JSDoc comments.buildSettingKeyto use?? nullfor the now-optionalretryAttemptNumberfield, keeping deduplication logic consistent.Flowchart
%%{init: {'theme': 'neutral'}}%% flowchart TD A[Attempt fails with Anthropic 400] --> B{Is Anthropic provider?} B -- No --> Z[Normal error handling] B -- Yes --> C{Signature or Budget\nrectifier trigger?} C -- No --> Z C -- Yes --> D[Set errorCategory =\nNON_RETRYABLE_CLIENT_ERROR] D --> E{Acquire rectifierLock} E --> F{settled or\nwinnerCommitted?} F -- Yes --> R[return false\nnot handled] F -- No --> G{attempt.requestVersion\n< requestVersion?} G -- Yes stale --> H{hasProviderInFlight\nwith current version?} H -- Yes --> I[return true\nstale, sibling handles it] H -- No --> J{currentAttemptNumber\n< maxAttemptsPerProvider?} J -- Yes --> K[startAttempt same provider\nallowDuplicate] J -- No --> L[launchAlternative\nfinishIfExhausted] K --> M[return true handled] L --> M G -- No fresh --> N{providerID already\nin rectifierRetriedSet?} N -- Yes --> R N -- No --> O[rectifyAnthropicRequestMessage\nmutate session.request.message] O --> P{rectified.applied?} P -- No --> R P -- Yes --> Q[requestVersion++\naddProviderToChain reason=retry_failed] Q --> S{attempt.providerAttemptNumber\n< maxAttemptsPerProvider?\nhasRetryBudget} S -- Yes --> T[add to rectifierRetriedSet\nstartAttempt same provider] S -- No --> U[launchAlternative\nfinishIfExhausted] T --> M U --> MComments Outside Diff (3)
src/app/v1/_lib/proxy/forwarder.ts, line 1108-1118 (link)retryAttemptNumberaudit field is misleading in the provider-switch pathretryAttemptNumber: attemptCount + 1is recorded unconditionally inaddSpecialSetting, before thehasRetryBudgetcheck lower in the same block. WhenhasRetryBudgetisfalse, no retry will occur on this provider — the code breaks out and either switches provider or throws. The audit record therefore claims a retry atattemptCount + 1that never happens on this provider, which could confuse any tooling or monitoring that consumes these records.Consider computing and passing
retryAttemptNumberconditionally (or using a separate field likeswitchedProvider: truefor the no-budget branch), or populating the field only after determining whether a retry will actually take place. The same issue applies to the thinking budget rectifier'saddSpecialSettingcall at the equivalent location in section 2.6.Prompt To Fix With AI
tests/unit/proxy/proxy-forwarder-retry-limit.test.ts, line 993-1059 (link)Missing streaming hedge + budget rectifier maxRetryAttempts=2 test
The streaming hedge tests cover the signature rectifier for both
maxRetryAttempts=1(switch provider, line 859) andmaxRetryAttempts=2(retry same provider, line 931), and the budget rectifier formaxRetryAttempts=1(line 993). However, there is no corresponding test forstreaming hedge + thinking budget rectifier: maxRetryAttempts=2 should retry same provider.Without this test, the budget rectifier's per-provider retry path (
hasRetryBudget=true→budgetRectifierRetriedProviderIds.add→ re-launch viastartAttempt(…, { allowDuplicateProvider: true })) instreamingHedgehas no coverage, leaving an asymmetry with the signature rectifier path that IS covered. Consider adding a parallel test:Prompt To Fix With AI
tests/unit/proxy/proxy-forwarder-retry-limit.test.ts, line 1556-1599 (link)Streaming hedge tests missing
getPreferredProviderEndpointssetupThe streaming hedge tests (starting here) set
stream = trueand rely on thestreamingHedgecode path, which callsgetPreferredProviderEndpointsinternally for endpoint selection insidestartAttempt. However, unlike the"raw passthrough policy parity"describe block (line ~979), there is no explicitmocks.getPreferredProviderEndpoints.mockResolvedValue(...)call inside these streaming tests.Because
vi.clearAllMocks()inbeforeEachclears recorded calls but does not reset implementations, these tests implicitly depend on whatever stategetPreferredProviderEndpointswas left in by a previously-run test. If test execution order changes (e.g., test file isolation,--randomizeflag, or future test additions),getPreferredProviderEndpointscould returnundefinedor[], causingstartAttemptto bail out early on endpoint selection failure beforedoForwardis even called — silently invalidating these tests.Add an explicit mock setup at the top of each streaming hedge test:
Prompt To Fix With AI
Prompt To Fix All With AI
Last reviewed commit: 6f6daf6