Skip to content

Commit 86bb419

Browse files
authored
fix(runtime): classify provider capacity errors (#3365)
* fix(runtime): classify provider capacity errors Fixes #3341 Generated-by: Codex * fix(runtime): refine provider capacity retry classification Keep xAI capacity errors distinct from quota exhaustion and fall back to bounded local retry when Retry-After is malformed. Generated-by: gpt-5.6-sol * fix(runtime): preserve provider capacity retry reason Generated-by: gpt-5.6-sol * fix(runtime): prioritize capacity evidence safely * fix(runtime): preserve capacity diagnostics
1 parent 724e7c0 commit 86bb419

19 files changed

Lines changed: 326 additions & 26 deletions
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
/*
2+
* Licensed to the Apache Software Foundation (ASF) under one
3+
* or more contributor license agreements. See the NOTICE file
4+
* distributed with this work for additional information
5+
* regarding copyright ownership. The ASF licenses this file
6+
* to you under the Apache License, Version 2.0 (the
7+
* "License"); you may not use this file except in compliance
8+
* with the License. You may obtain a copy of the License at
9+
*
10+
* http://www.apache.org/licenses/LICENSE-2.0
11+
*
12+
* Unless required by applicable law or agreed to in writing,
13+
* software distributed under the License is distributed on an
14+
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
15+
* KIND, either express or implied. See the License for the
16+
* specific language governing permissions and limitations
17+
* under the License.
18+
*/
19+
20+
import assert from 'node:assert/strict';
21+
import { describe, it } from 'node:test';
22+
23+
import { describeSessionErrorReason } from '../../renderer/session-error-presentation.js';
24+
import { deriveFailedTurnRecovery, describeTurnErrorClass } from '../../renderer/session-status-presentation.js';
25+
26+
describe('provider capacity presentation', () => {
27+
it('uses capacity-specific copy instead of the unknown error fallback', () => {
28+
assert.match(describeSessionErrorReason('provider_capacity') ?? '', //);
29+
assert.match(describeTurnErrorClass('provider_capacity'), //);
30+
});
31+
32+
it('does not recommend an immediate direct retry', () => {
33+
const recovery = deriveFailedTurnRecovery({
34+
errorClass: 'provider_capacity',
35+
partialOutputRetained: false,
36+
toolActivityCount: 0,
37+
erroredToolCount: 0,
38+
});
39+
assert.equal(recovery.action, 'retry');
40+
assert.match(recovery.label, /|/);
41+
assert.doesNotMatch(recovery.label, //);
42+
});
43+
});

apps/desktop/src/main/__tests__/session-status-presentation.test.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,5 +79,19 @@ describe('failed turn recovery presentation', () => {
7979
),
8080
{ action: 'inspect_tool', label: 'Inspect the tool result before retrying' },
8181
);
82+
assert.deepEqual(
83+
deriveFailedTurnRecovery(
84+
{ ...outputFreeFailure, errorClass: 'provider_capacity', partialOutputRetained: true },
85+
'en',
86+
),
87+
{ action: 'continue', label: 'Partial output was retained; continue from here' },
88+
);
89+
assert.deepEqual(
90+
deriveFailedTurnRecovery(
91+
{ ...outputFreeFailure, errorClass: 'provider_capacity', toolActivityCount: 1 },
92+
'en',
93+
),
94+
{ action: 'inspect_tool', label: 'Tool history was retained; inspect it before retrying' },
95+
);
8296
});
8397
});

apps/desktop/src/renderer/locales/conversation-copy.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -307,6 +307,7 @@ export interface DesktopConversationCopy {
307307
timeout: string;
308308
auth: string;
309309
providerBilling: string;
310+
providerCapacity: string;
310311
rateLimit: string;
311312
network: string;
312313
provider: string;
@@ -315,7 +316,7 @@ export interface DesktopConversationCopy {
315316
permission: string;
316317
restarted: string;
317318
sandboxBoundaryClosed: string;
318-
recovery: Record<'safeResume' | 'stepCap' | 'toolError' | 'connection' | 'partial' | 'toolRecord' | 'retry' | 'contextOverflow' | 'sandboxBoundaryClosed', string>;
319+
recovery: Record<'safeResume' | 'stepCap' | 'toolError' | 'connection' | 'partial' | 'toolRecord' | 'retry' | 'capacity' | 'contextOverflow' | 'sandboxBoundaryClosed', string>;
319320
};
320321
}
321322

@@ -588,7 +589,7 @@ const COPY = {
588589
reauth: { label: '上次连接测试鉴权失败', tooltip: '最近一次连接测试返回鉴权失败(401 / 403),密钥可能已过期或被吊销。这不会拦截发送,但若发送失败请到 设置 · 模型 重新登录。' },
589590
testError: { label: '上次连接测试失败', tooltip: '最近一次连接测试因网络 / 超时 / 5xx 失败。这不会拦截发送,但若问题持续请到 设置 · 模型 检查 Base URL / 代理。' },
590591
},
591-
turnError: { unknown: '未知错误', contextOverflow: '上下文窗口已超出限制', timeout: '请求超时', auth: '鉴权失败', providerBilling: '模型服务计费受限', rateLimit: '触发模型速率限制', network: '网络错误', provider: '模型服务返回错误', stepCap: '达到工具步骤上限', tool: '工具调用失败', permission: '等待权限确认', restarted: '本地应用重启,上一轮没有完成', sandboxBoundaryClosed: '本地应用重启,等待确认的「允许访问工作区以外的内容」请求已按拒绝关闭', recovery: { safeResume: '检查当前状态后,可尝试安全恢复', stepCap: '任务可能尚未完成,可以继续', toolError: '先检查工具结果,再决定是否重试', connection: '先检查模型连接或登录状态', partial: '已保留部分输出,可从这里继续', toolRecord: '工具记录已保留,重试前先看结果', retry: '没有执行工具,可直接重试', contextOverflow: '上下文仍超出限制,请减少附件或开启新任务', sandboxBoundaryClosed: '访问范围没有放开,重试本轮后可重新决定' } },
592+
turnError: { unknown: '未知错误', contextOverflow: '上下文窗口已超出限制', timeout: '请求超时', auth: '鉴权失败', providerBilling: '模型服务计费受限', providerCapacity: '模型服务暂时满载,请稍后重试或切换模型', rateLimit: '触发模型速率限制', network: '网络错误', provider: '模型服务返回错误', stepCap: '达到工具步骤上限', tool: '工具调用失败', permission: '等待权限确认', restarted: '本地应用重启,上一轮没有完成', sandboxBoundaryClosed: '本地应用重启,等待确认的「允许访问工作区以外的内容」请求已按拒绝关闭', recovery: { safeResume: '检查当前状态后,可尝试安全恢复', stepCap: '任务可能尚未完成,可以继续', toolError: '先检查工具结果,再决定是否重试', connection: '先检查模型连接或登录状态', partial: '已保留部分输出,可从这里继续', toolRecord: '工具记录已保留,重试前先看结果', retry: '没有执行工具,可直接重试', capacity: '模型服务暂时满载,请等待几分钟或切换模型后重试', contextOverflow: '上下文仍超出限制,请减少附件或开启新任务', sandboxBoundaryClosed: '访问范围没有放开,重试本轮后可重新决定' } },
592593
},
593594
en: {
594595
actions: { stopFailedTitle: 'Failed to stop', stopFailedFallback: 'The task action failed. Try again later.', refreshSessionsFailedTitle: 'Failed to refresh tasks', refreshSessionsFailedFallback: 'The task list could not be refreshed. Try again later.', conversationErrorTitle: 'Task error', conversationErrorFallback: 'The task run failed. Try again later.', regenerateStartedTitle: 'Regeneration started', regenerateStartedDescription: 'Generating a new response', branchCreatedTitle: 'Branch created', branchCreatedDescription: (name) => `New task: ${name}`, revisionStartedTitle: 'Edit draft ready', revisionStartedDescription: 'The original task is kept; sending creates a new version', revisionReadyTitle: 'Ready to edit and resend', revisionReadyDescription: 'Rewound to before that message; edit and send when ready', revisionUnavailableTitle: 'This message cannot be edited yet', revisionAttachmentsUnsupported: 'Edit & resend does not yet support historical attachments. Copy the text into a new message instead.', revisionTransformedTextUnsupported: 'Edit & resend does not yet support messages sent with an explicit skill. Copy the text and select the skill again instead.', revisionDraftAttachmentConflict: 'The composer already has pending attachments. Send or remove them before editing a sent message.', revisionCommandUnsupported: 'You cannot run /compact, /side, or orchestration commands while editing a sent message. Cancel the edit first.', revisionAlreadyActive: 'Another message is already being edited. Send or cancel that edit first.', revisionCancelLabel: 'Cancel', revisionBannerTitle: 'Editing sent message', revisionBannerDetail: '· New version on send', revisionUnchanged: 'Nothing changed. Use Regenerate if you only want a new answer.', operationFailedTitle: 'Action failed', operationFailedFallback: 'The task action failed. Try again later.', attachmentFailedTitle: 'Failed to add attachment', tryAgain: 'Try again later.', modelReboundTitle: 'Switched to an available model', modelReboundDescription: (modelId) => `The previous connection is unavailable${modelId ? ` · ${modelId}` : ''}`, messageReadFailedTitle: 'Failed to load task', returnLatest: 'Return to latest', scrollMainToBottom: 'Scroll main conversation to bottom' },
@@ -794,7 +795,7 @@ const COPY = {
794795
reauth: { label: 'Last connection test failed authentication', tooltip: 'The latest test returned 401 / 403. Sending is not blocked, but sign in again under Settings · Models if it fails.' },
795796
testError: { label: 'Last connection test failed', tooltip: 'The latest test failed because of a network, timeout, or 5xx error. Sending is not blocked; check Base URL or proxy settings if it persists.' },
796797
},
797-
turnError: { unknown: 'Unknown error', contextOverflow: 'Context window exceeded', timeout: 'Request timed out', auth: 'Authentication failed', providerBilling: 'Provider billing required', rateLimit: 'Model rate limit reached', network: 'Network error', provider: 'Model service error', stepCap: 'Tool-step limit reached', tool: 'Tool call failed', permission: 'Waiting for permission', restarted: 'The app restarted before the previous turn completed', sandboxBoundaryClosed: 'The app restarted, so the pending request to reach outside the workspace was closed as denied', recovery: { safeResume: 'Inspect the current state, then try safe recovery', stepCap: 'The task may be incomplete; continue from here', toolError: 'Inspect the tool result before retrying', connection: 'Check the model connection or sign-in status', partial: 'Partial output was retained; continue from here', toolRecord: 'Tool history was retained; inspect it before retrying', retry: 'No tools ran; retry directly', contextOverflow: 'Context is still too large; reduce attachments or start a new task', sandboxBoundaryClosed: 'Access was not widened; retry the turn to decide again' } },
798+
turnError: { unknown: 'Unknown error', contextOverflow: 'Context window exceeded', timeout: 'Request timed out', auth: 'Authentication failed', providerBilling: 'Provider billing required', providerCapacity: 'The model service is temporarily at capacity. Wait and retry, or switch models.', rateLimit: 'Model rate limit reached', network: 'Network error', provider: 'Model service error', stepCap: 'Tool-step limit reached', tool: 'Tool call failed', permission: 'Waiting for permission', restarted: 'The app restarted before the previous turn completed', sandboxBoundaryClosed: 'The app restarted, so the pending request to reach outside the workspace was closed as denied', recovery: { safeResume: 'Inspect the current state, then try safe recovery', stepCap: 'The task may be incomplete; continue from here', toolError: 'Inspect the tool result before retrying', connection: 'Check the model connection or sign-in status', partial: 'Partial output was retained; continue from here', toolRecord: 'Tool history was retained; inspect it before retrying', retry: 'No tools ran; retry directly', capacity: 'The model service is at capacity. Wait a few minutes or switch models before retrying.', contextOverflow: 'Context is still too large; reduce attachments or start a new task', sandboxBoundaryClosed: 'Access was not widened; retry the turn to decide again' } },
798799
},
799800
} satisfies UiCatalog<DesktopConversationCopy>;
800801

apps/desktop/src/renderer/session-error-presentation.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,8 @@ export function describeSessionErrorReason(reason: string | undefined, locale: U
3636
return copy.auth;
3737
case 'provider_billing':
3838
return copy.providerBilling;
39+
case 'provider_capacity':
40+
return copy.providerCapacity;
3941
case 'provider_unavailable':
4042
return copy.provider;
4143
case 'rate_limit':

apps/desktop/src/renderer/session-status-presentation.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,9 @@ export function deriveFailedTurnRecovery(input: FailedTurnRecoveryInput, locale:
204204
if (input.toolActivityCount > 0) {
205205
return { action: 'inspect_tool', label: copy.toolRecord };
206206
}
207+
if (lower === 'provider_capacity') {
208+
return { action: 'retry', label: copy.capacity };
209+
}
207210
if (lower === 'context_overflow') {
208211
return { action: 'continue', label: copy.contextOverflow };
209212
}

packages/core/src/events.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1096,6 +1096,7 @@ export interface QueueUpdateEvent extends BaseEvent {
10961096

10971097
export type ProviderRetryReason =
10981098
| 'network'
1099+
| 'provider_capacity'
10991100
| 'provider_unavailable'
11001101
| 'rate_limit'
11011102
| 'timeout'

packages/runtime-host/src/__tests__/handshake-compatibility.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -108,7 +108,7 @@ test('receives structured incompatibility guidance from the released v0.1.11 Hos
108108
);
109109
});
110110

111-
test('rejects an epoch-23 Host before any domain command', async () => {
111+
test('rejects an epoch-39 Host before any domain command', async () => {
112112
let admittedRequest: RequestFrame | undefined;
113113
await withForgedHandshakePeer(
114114
async (transport, hostEpoch, rootId) => {
@@ -120,7 +120,7 @@ test('rejects an epoch-23 Host before any domain command', async () => {
120120
hostEpoch,
121121
connectionId: 'forged-epoch-connection',
122122
selectedProtocol: RUNTIME_HOST_PROTOCOL_VERSION,
123-
compatibilityEpoch: 23,
123+
compatibilityEpoch: 39,
124124
compositionId: 'maka.interactive',
125125
compositionRevision: '1',
126126
state: 'ready',

packages/runtime-host/src/__tests__/protocol.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -205,8 +205,8 @@ describe('Runtime Host bootstrap protocol', () => {
205205
);
206206
});
207207

208-
test('publishes a new compatibility epoch for typed context compaction outcomes', () => {
209-
assert.ok(RUNTIME_HOST_COMPATIBILITY_EPOCH > 39);
208+
test('publishes a new compatibility epoch for provider capacity retry progress', () => {
209+
assert.ok(RUNTIME_HOST_COMPATIBILITY_EPOCH > 41);
210210
});
211211

212212
test('selects the highest mutually supported protocol and rejects a gap', () => {

packages/runtime-host/src/protocol/index.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,9 @@ export const RUNTIME_HOST_REGISTRATION_SCHEMA_VERSION = 1 as const;
9191
export const RUNTIME_HOST_PROTOCOL_VERSION = 0 as const;
9292
// Increment when the same protocol version no longer guarantees safe Client-Host
9393
// interoperability. Mismatches are rejected before domain commands are admitted.
94-
export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 41 as const;
94+
export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 42 as const;
95+
// 42: Turn provider retry progress adds `provider_capacity`. Older peers reject
96+
// that strict retry-reason enum value, so mixed versions must fail handshake.
9597
// 41: Context compaction returns a typed terminal outcome on both Turn
9698
// snapshots and context.compact results. Epoch-40 peers reject these closed
9799
// shapes after admission, so mixed peers must fail during the handshake.

packages/runtime-host/src/protocol/turn.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -762,6 +762,7 @@ export function decodeTurnProviderRetry(value: unknown): TurnProviderRetry {
762762
function requireProviderRetryReason(value: unknown): ProviderRetryReason {
763763
if (
764764
value === 'network' ||
765+
value === 'provider_capacity' ||
765766
value === 'provider_unavailable' ||
766767
value === 'rate_limit' ||
767768
value === 'timeout' ||

0 commit comments

Comments
 (0)