タイムアウト駆動型のAuth Rotationが時期尚早にProvider Fallbackをトリガーする
汎用的なリクエストタイムアウトが誤ってレート制限信号として扱われ、過剰なAuth Profileのクールダウンとローテーション,引起甚至是当providerが一時的に遅い場合에도 provider/modelフォールバックにカスケードする.
🔍 症状
主なエラーメッセージ
auth.profiles をサポートするプロバイダでリクエストタイムアウトが発生すると、エンベデッドランナーはカスケード_failureメッセージを出力します:
Profile openai-codex:default timed out (possible rate limit). Trying next account...
No available auth profile for openai-codex (all in cooldown or unavailable).
... provider=openai model=gpt-5.2 ... # fallback triggered
観測可能な動作
- プロファイルの早期消費: 1つのプロファイルでの単一のタイムアウトが、利用可能な次のプロファイルへの即時ローテーションを引き起こす
- クールダウン状態の蓄積: 各タイムアウトが指数関数的バックオフ(約1分→5分→25分→1時間上限)でクールダウンエントリを書き込む
- 不必要的モデルフォールバック: すべてのプロファイルがクールダウン状態に入ると、元のプロバイダが動作中であっても、システムが設定されたモデルフォールバックに進む
- ログノイズ: 繰り返し発生する `timed out (possible rate limit)` メッセージにより、実際のレート制限状況に関する混乱が生じる
再現シナリオ
bash
トリガー: リクエストがtimeoutSecondsしきい値を超える
openclaw run –agent ./my-agent.ts –timeout-seconds 30
観測結果: リトライなしで即座にauth profileがローテート
期待結果: ローテーション前に少なくとも1回のバックオフ付きリトライ
影響を受けるコンポーネント
| コンポーネント | ファイルパス | 障害発生箇所 |
|---|---|---|
| Embedded Runner | src/agents/pi-embedded-runner/run.ts | タイムアウト → markAuthProfileFailure() → advanceAuthProfile() |
| Auth Profiles | src/agents/auth-profiles/usage.ts | タイムアウトとレート制限の理由に対して均一なクールダウンスケジュール |
🧠 原因
アーキテクチャ分析
エンベデッドランナーのauth-profileフェイルオーバーループは、2つの異なる障害モードを混同しています:
- 強いレート制限シグナル: HTTP 429、プロバイダー固有のエラーコード(例:
error.code === "rate_limit_exceeded") - 弱い一時的シグナル: 汎用リクエストタイムアウト(ネットワークの一時的問題、遅いストリーミング、SDKレイテンシー急上昇)
コードパスの解析
ファイル: src/agents/pi-embedded-runner/run.ts
タイムアウトハンドラーはリトライゲートなし実行します:
typescript // 簡略化したフロー(行数は概算) async function executeWithAuthProfile(provider, profile, request) { try { const result = await executeRequest(request, { timeout: timeoutMs }); return result; } catch (error) { if (isTimeout(error)) { // ❌ リトライゲートなし - 即座に失敗をマーク markAuthProfileFailure(profile, { reason: “timeout” }); advanceAuthProfile(provider); // ← ローテーションをトリガー throw new NoAvailableAuthProfileError(provider); }
if (isRateLimit(error)) {
// ✓ 正しい: 強いシグナルは即座のクールダウンに値する
markAuthProfileFailure(profile, { reason: "rate_limit" });
advanceAuthProfile(provider);
throw new NoAvailableAuthProfileError(provider);
}
} }
ファイル: src/agents/auth-profiles/usage.ts
クールダウン計算はすべての障害理由に対して同一の指数関数的スケジュールを適用します:
typescript function calculateAuthProfileCooldownMs(errorCount: number): number { // 約1分 → 5分 → 25分 → 1時間上限 const baseMs = 60_000; const cooldown = baseMs * Math.pow(5, Math.min(errorCount - 1, 3)); return Math.min(cooldown, 3_600_000); // 1時間上限 }
// “timeout” と “rate_limit” の理由に対して同一に呼び出される
障害カスケードシーケンス
- リクエストタイムアウトが発生
- markAuthProfileFailure(reason: “timeout”) がクールダウンエントリを書き込む
- advanceAuthProfile() が次のプロファイルにローテート
- すべてのプロファイルが利用不可の場合: a. NoAvailableAuthProfileError が発生 b. agents.defaults.model.fallbacks をチェック c. フォールバックモデル/プロバイダに進む ← 時期尚早!
- フォールバックがない場合: リクエストが完全に失敗
これが誤りである理由
| シグナルタイプ | 信頼性 | 適切な対応 |
|---|---|---|
| HTTP 429 | 高 | 即座のクールダウン + ローテート |
| プロバイダーエラーコード | 高 | 即座のクールダウン + ローテート |
| 汎用タイムアウト | 低(一時的) | クールダウン前にバックオフ付きリトライ |
汎用タイムアウトは以下と区別できません:
- 一時的なネットワークレイテンシーの急上昇
- 遅いストリーミング応答の開始
- SDK接続オーバーヘッド
- 一時的なプロバイダー側の負荷
設定のギャップ
理由ごとのリトライ動作を制御する設定は存在しません:
typescript // 現在: retrySameProfileOnTimeout 設定は存在しない agents: { defaults: { timeoutSeconds: 30, modelFailover: { // 欠落: retrySameProfileOnTimeout, retryBackoffMs } } }
🛠️ 解決手順
推奨: 最小リトライゲート追加
この修正は、クールダウンとローテーションをトリガーする前に、タイムアウト障害に対して理由ごとのリトライゲートを追加します。
ステップ1: 設定スキーマの拡張
ファイル: src/config/schema.ts
モデルフェイルオーバー設定に新しいフィールドを追加します:
typescript // 修正前 interface ModelFailoverConfig { fallbacks: string[]; }
// 修正後 interface ModelFailoverConfig { fallbacks: string[]; retrySameProfileOnTimeout: number; // デフォルト: 1 retryBackoffMs: [number, number]; // デフォルト: [300, 1200] ms(最小値、最大ジッター) }
ステップ2: エンベデッドランナーでのリトライゲートの実装
ファイル: src/agents/pi-embedded-runner/run.ts
タイムアウト処理をリトライロジックを含むように変更します:
typescript // 修正前 async function executeWithAuthProfile(provider, profile, request) { try { return await executeRequest(request, { timeout: timeoutMs }); } catch (error) { if (isTimeout(error)) { markAuthProfileFailure(profile, { reason: “timeout” }); advanceAuthProfile(provider); throw new NoAvailableAuthProfileError(provider); } // … rate limit handling } }
// 修正後 async function executeWithAuthProfile(provider, profile, request, options = {}) { const config = getConfig(); const { retrySameProfileOnTimeout = 1, retryBackoffMs = [300, 1200] } = config.agents?.defaults?.modelFailover ?? {};
// プロファイルごとのリトライをセッションごとに追跡 const retryState = getOrCreateRetryState(profile.id);
try { return await executeRequest(request, { timeout: timeoutMs }); } catch (error) { if (isTimeout(error)) { const maxRetries = retrySameProfileOnTimeout; const currentRetries = retryState.consecutiveTimeouts;
if (currentRetries < maxRetries) {
// ジッター付きバックオフで同じプロファイルをリトライ
const [minDelay, maxDelay] = retryBackoffMs;
const delay = minDelay + Math.random() * (maxDelay - minDelay);
console.log(
`Profile ${profile.id} timed out. ` +
`Retry ${currentRetries + 1}/${maxRetries} in ${Math.round(delay)}ms...`
);
retryState.consecutiveTimeouts++;
await sleep(delay);
// 同じプロファイルで再実行(クールダウンなし)
return await executeWithAuthProfile(
provider, profile, request,
{ ...options, isRetry: true }
);
}
// リトライを使い果たした場合: クールダウン + ローテート
console.log(
`Profile ${profile.id} timed out (${maxRetries} retries exhausted). ` +
`Trying next account...`
);
markAuthProfileFailure(profile, { reason: "timeout" });
clearRetryState(profile.id); // リトライカウンターをリセット
advanceAuthProfile(provider);
throw new NoAvailableAuthProfileError(provider);
}
// レート制限処理は変更なし(即座にクールダウン)
if (isRateLimit(error)) {
markAuthProfileFailure(profile, { reason: "rate_limit" });
clearRetryState(profile.id);
advanceAuthProfile(provider);
throw new NoAvailableAuthProfileError(provider);
}
throw error;
} }
ステップ3: リトライ状態管理を追加
ファイル: src/agents/auth-profiles/retry-state.ts (新しいファイル)
typescript interface RetryState { consecutiveTimeouts: number; lastRetryTimestamp: number; }
const retryStateMap = new Map<string, RetryState>();
export function getOrCreateRetryState(profileId: string): RetryState { if (!retryStateMap.has(profileId)) { retryStateMap.set(profileId, { consecutiveTimeouts: 0, lastRetryTimestamp: 0 }); } return retryStateMap.get(profileId)!; }
export function clearRetryState(profileId: string): void { retryStateMap.delete(profileId); }
export function clearAllRetryStates(): void { retryStateMap.clear(); }
ステップ4: デフォルト設定の更新
ファイル: src/config/defaults.ts
typescript // 修正前 export const defaultAgentsConfig = { defaults: { timeoutSeconds: 30, modelFailover: { fallbacks: [] } } };
// 修正後 export const defaultAgentsConfig = { defaults: { timeoutSeconds: 30, modelFailover: { fallbacks: [], retrySameProfileOnTimeout: 1, retryBackoffMs: [300, 1200] } } };
修正後の設定
json5 { “agents”: { “defaults”: { “timeoutSeconds”: 30, “modelFailover”: { “fallbacks”: [“gpt-4-turbo”, “claude-3-opus”], “retrySameProfileOnTimeout”: 1, // クールダウン前のリトライ回数(0 = 無効) “retryBackoffMs”: [300, 1200] // [最小, 最大] ミリ秒単位のジッター付き遅延 } } } }
オプション: 理由ごとのクールダウンスケジュール
より洗練された修正として、理由別にクールダウンスケジュールを区別します:
ファイル: src/agents/auth-profiles/usage.ts
typescript const COOLDOWN_SCHEDULES = { timeout: { baseMs: 10_000, // 10秒(rate-limit は60秒) multiplier: 2, // 10秒 → 20秒 → 40秒 → 80秒 capMs: 300_000 // 5分上限(1時間相比) }, rate_limit: { baseMs: 60_000, multiplier: 5, // 60秒 → 5分 → 25分 → 1時間 capMs: 3_600_000 // 1時間上限 } };
export function calculateAuthProfileCooldownMs( errorCount: number, reason: ’timeout’ | ‘rate_limit’ ): number { const schedule = COOLDOWN_SCHEDULES[reason]; const cooldown = schedule.baseMs * Math.pow(schedule.multiplier, Math.min(errorCount - 1, 3)); return Math.min(cooldown, schedule.capMs); }
🧪 検証
ユニットテスト: 単一タイムアウトで同じプロファイルをリトライ
ファイル: src/agents/pi-embedded-runner/__tests__/timeout-retry.test.ts
typescript describe(‘Timeout retry behavior’, () => { const mockProfile = { id: ’test-profile’, provider: ‘openai-codex’ };
beforeEach(() => { clearAllRetryStates(); });
test(‘single timeout retries same profile without cooldown’, async () => { const executeRequest = jest.fn() .mockRejectedValueOnce(new TimeoutError()) .mockResolvedValueOnce({ data: ‘success’ });
const markAuthProfileFailure = jest.fn();
const advanceAuthProfile = jest.fn();
await executeWithAuthProfile('openai-codex', mockProfile, mockRequest, {
executeRequest,
markAuthProfileFailure,
advanceAuthProfile,
config: { retrySameProfileOnTimeout: 1, retryBackoffMs: [0, 10] }
});
// リトライが発生したことを確認
expect(executeRequest).toHaveBeenCalledTimes(2);
// クールダウンが書き込まれなかったことを確認
expect(markAuthProfileFailure).not.toHaveBeenCalled();
// ローテーションが発生しなかったことを確認
expect(advanceAuthProfile).not.toHaveBeenCalled();
});
test(’exhausted retries triggers cooldown and rotation’, async () => { const executeRequest = jest.fn() .mockRejectedValue(new TimeoutError());
const markAuthProfileFailure = jest.fn();
const advanceAuthProfile = jest.fn();
await expect(
executeWithAuthProfile('openai-codex', mockProfile, mockRequest, {
executeRequest,
markAuthProfileFailure,
advanceAuthProfile,
config: { retrySameProfileOnTimeout: 1, retryBackoffMs: [0, 10] }
})
).rejects.toThrow(NoAvailableAuthProfileError);
// リトライを使い果たしたことを確認
expect(executeRequest).toHaveBeenCalledTimes(2);
// クールダウンが適用されたことを確認
expect(markAuthProfileFailure).toHaveBeenCalledWith(
mockProfile,
{ reason: 'timeout' }
);
// ローテーションが発生ことを確認
expect(advanceAuthProfile).toHaveBeenCalledWith('openai-codex');
});
test(‘rate-limit triggers immediate cooldown (no retry)’, async () => { const executeRequest = jest.fn().mockRejectedValue({ status: 429, code: ‘rate_limit_exceeded’ });
const markAuthProfileFailure = jest.fn();
const advanceAuthProfile = jest.fn();
await expect(
executeWithAuthProfile('openai-codex', mockProfile, mockRequest, {
executeRequest,
markAuthProfileFailure,
advanceAuthProfile,
config: { retrySameProfileOnTimeout: 1, retryBackoffMs: [0, 10] }
})
).rejects.toThrow(NoAvailableAuthProfileError);
// rate-limit のリトライがないことを確認
expect(executeRequest).toHaveBeenCalledTimes(1);
expect(markAuthProfileFailure).toHaveBeenCalledWith(
mockProfile,
{ reason: 'rate_limit' }
);
}); });
統合テスト: 複数プロファイル + 断続的なタイムアウト
typescript test(‘intermittent timeouts do not exhaust all profiles’, async () => { const profiles = [ { id: ‘profile-1’, provider: ‘openai-codex’ }, { id: ‘profile-2’, provider: ‘openai-codex’ }, { id: ‘profile-3’, provider: ‘openai-codex’ } ];
// Profile 1: timeout → retry → success // Profile 2: timeout → retry → timeout → cooldown // Profile 3: success const executeRequest = jest.fn() .mockImplementation(({ profile }) => { if (profile.id === ‘profile-1’) return Promise.resolve({ data: ‘ok’ }); if (profile.id === ‘profile-2’) return Promise.reject(new TimeoutError()); if (profile.id === ‘profile-3’) return Promise.resolve({ data: ‘ok’ }); });
const result = await runWithAuthProfiles(profiles, mockRequest, { executeRequest, config: { retrySameProfileOnTimeout: 1, retryBackoffMs: [0, 10] } });
// profile-1 または profile-3 を使用して成功するはず expect(result).toBeDefined();
// profile-2 のクールダウンが記録されるはず expect(getProfileCooldown(‘profile-2’)).toBeDefined(); expect(getProfileCooldown(‘profile-3’)).toBeUndefined(); });
手動検証手順
bash
1. デバッグログを有効化
export OPENCLAW_LOG_LEVEL=debug
2. 既知のタイムアウトが発生しやすいシナリオでエージェントを実行
openclaw run –agent ./test-agent.ts –timeout-seconds 5
3. 修正時の期待されるログ出力:
[DEBUG] Profile openai-codex:default timed out. Retry 1/1 in 847ms…
[DEBUG] Request succeeded on retry
最初期のタイムアウトで “Trying next account…” は表示されない
4. リトライを使い果たした後の出力:
[INFO] Profile openai-codex:default timed out (1 retries exhausted). Trying next account…
[INFO] Cooldown applied: 10000ms for timeout reason
検証チェックリスト
| 基準 | テスト方法 | 期待結果 |
|---|---|---|
| 単一タイムアウトで同じプロファイルをリトライ | ユニットテスト | executeRequest 2回呼び出し、クールダウン書き込み 0回 |
| リトライ使い果たし → クールダウン | ユニットテスト | markAuthProfileFailure が reason: “timeout” で呼び出される |
| レート制限はリトライをバイパス | ユニットテスト | executeRequest 1回呼び出し、即座にクールダウン |
| ログ出力の正確性 | 手動テスト | クールダウン前にリトライ回数と遅延が表示される |
| プロファイル消耗の防止 | 統合テスト | 3回の断続的タイムアウトで最大2プロファイルを使用 |
⚠️ よくある落とし穴
エッジケースと環境固有のトラップ
- ジッター範囲が狭すぎる:
retryBackoffMsが小さすぎる場合(例:[1, 10])、リトライが同じ一時的問題すぐに-hitする可能性があります。推奨最小値:[300, 1200] - 無限リتيループのリスク:
retrySameProfileOnTimeoutをグローバルタイムアウトなし非常に高く設定すると、リクエストが無期限にハングする可能性があります。timeoutSecondsと常にペアで使用してください - セッション間のリトライ状態のリーク: プロファイルローテーション成功時に
clearRetryState()を呼び出すようにして、古いリトライカウントを防ぎます - 長時間実行プロセスのメモリ圧力: リトライ状態マップはプロファイルオブジェクトに対して WeakMap または明示的なクリーンアップを使用する必要があります
macOS固有の考慮事項
bash
ネットワーク遅延シミュレーションは異なる場合があります
以下でテスト: sudo scutil –set InitialTSR 5000
Docker固有の考慮事項
bash
コンテナネットワークのタイムアウトはリソース制約によって異なる場合があります
コンテナがタイムアウト処理を適切に行えるよう適切なリソースを確保:
docker run –memory=512m –cpus=1 …
Windows固有の考慮事項
powershell
PowerShell の sleep 精度は Unix と異なります
monotonic クロックを使用するよう sleep 実装を確認:
[System.Diagnostics.Stopwatch]::GetTimestamp()
設定の落とし穴
typescript // ❌ 間違い: retryBackoffMs が逆順(最小値 > 最大値) { retryBackoffMs: [1200, 300] }
// ✅ 正しい: [最小値, 最大値] { retryBackoffMs: [300, 1200] }
// ❌ 間違い: retrySameProfileOnTimeout = 0 はすべてのタイムアウト処理を無効化 // (「リトライ無効」ではなく「無限リトライ」になるべき) { retrySameProfileOnTimeout: 0 }
// ✅ 正しい: 無効にするには、大きなバックオフまたは別個の設定を使用 { retrySameProfileOnTimeout: 0, timeoutRetriesEnabled: false }
既存のフォールバック動作との相互作用
agents.defaults.model.fallbacks が設定されている場合、リトライ動作はプロバイダーごとに適用されます:
json5 { “agents”: { “defaults”: { “modelFailover”: { “fallbacks”: [“gpt-4”, “claude-3”], “retrySameProfileOnTimeout”: 1, “retryBackoffMs”: [500, 2000] } } } }
修正時のシーケンス:
gpt-4-turboへのリクエストがopenai-codex:profile-1でタイムアウト- 同じ profile-1 をリトライ(クールダウン書き込みなし)
- リトライも失敗 → クールダウン +
profile-2へローテート - profile-2 も使い果たした場合 →
gpt-4へフォールバック(新しいプロファイル)
🔗 関連するエラー
文脈的に関連するエラーコードと過去の事例
| エラー / 問題 | 説明 | 関連性 |
|---|---|---|
NoAvailableAuthProfileError | すべてのプロファイルがクールダウン状態にあるときにスローされる | 攻撃的なタイムアウト処理の主な症状 |
Profile ${id} timed out (possible rate limit) | 誤解を招くログメッセージ | タイムアウトのみが発生した場所でレート制限を暗示 |
MARK_AUTH_PROFILE_FAILURE | Auth profile 失敗の追跡 | リトライゲートが必要なコアメカニズム |
| HTTP 429 | 明示的なレート制限シグナル | クールダウンの正しいトリガー(変更なし) |
error.code === “insufficient_quota” | プロバイダー固有のクォータエラー | 強いシグナル、リトライをバイパスすべき |
関連する設定パラメータ
| パラメータ | 現在の動作 | 問題点 |
|---|---|---|
agents.defaults.timeoutSeconds | プロファイルローテーションをトリガー | 一時的なタイムアウトには的攻击的 |
agents.defaults.modelFailover.fallbacks | すべてのプロファイルが使い果たされたときにトリガー | 単一のタイムアウトで不必要にトリガーされる |
agents.defaults.maxConcurrentRequests | タイムアウト問題を複雑にする可能性 | 高同時実行数 + タイムアウト = より速いプロファイル消耗 |
歴史的背景
この問題は設定によって異なる方法で現れます:
- 高トラフィックデプロイメント: 複数の同時タイムアウトがすべてのプロファイルを急速に使い果たす可能性がある
- 低トラフィックデプロイメント: 単一のタイムアウトが唯一のシグナルであっても、依然としてフォールバックを引き起こす
- 共有インフラストラクチャ: あるチームのタイムアウトが他のチームのプロファイル利用可能性に影響する
関連するOpenClawコンポーネントへの参照
src/agents/pi-embedded-runner/run.ts- エンベデッドランナーのauthループsrc/agents/auth-profiles/usage.ts- クールダウン計算src/agents/auth-profiles/cooldown-store.ts- 永続的なクールダウン状態src/config/schema.ts- 設定の型定義src/errors/auth-profile-errors.ts- エラークラスの定義