Timeout-gesteuerte Auth-Rotation löst vorzeitig Provider-Fallback aus
Allgemeine Request-Timeouts werden fälschlicherweise als Rate-Limit-Signale behandelt, was zu aggressivem Auth-Profile-Cooldown und -Rotation führt, die in Provider/Model-Fallback kaskadieren, selbst wenn der Provider vorübergehend langsam ist.
🔍 Symptome
Primäre Fehlermeldungen
Wenn bei einem Anbieter, der auth.profiles unterstützt, eine Anfrage-Zeitüberschreitung auftritt, gibt der eingebettete Runner kaskadierende Fehlermeldungen aus:
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
Beobachtbares Verhalten
- Vorzeitige Erschöpfung der Profile: Eine einzelne Zeitüberschreitung bei einem Profil führt zu einer sofortigen Rotation zum nächsten verfügbaren Profil
- Akkumulation des Cooldown-Zustands: Jede Zeitüberschreitung schreibt einen Cooldown-Eintrag mit exponentieller Rückkehr (~1m → 5m → 25m → 1h Deckelung)
- Unnötiger Modell-Fallback: Wenn alle Profile in den Cooldown-Modus wechseln, verwendet das System die konfigurierten Modell-Fallbacks, auch wenn der ursprüngliche Anbieter funktionsfähig ist
- Log-Rauschen: Wiederholte `timed out (possible rate limit)`-Meldungen erzeugen Verwirrung über den tatsächlichen Rate-Limiting-Status
Reproduktionsszenario
bash
Trigger: Eine einzelne Anfrage überschreitet den timeoutSeconds-Schwellenwert
openclaw run –agent ./my-agent.ts –timeout-seconds 30
Beobachtet: Sofortige Auth-Profile-Rotation ohne Wiederholung
Erwartet: Mindestens ein Wiederholungsversuch mit Backoff vor der Rotation
Betroffene Komponenten
| Komponente | Dateipfad | Fehlerstelle |
|---|---|---|
| Embedded Runner | src/agents/pi-embedded-runner/run.ts | Timeout → markAuthProfileFailure() → advanceAuthProfile() |
| Auth Profiles | src/agents/auth-profiles/usage.ts | Einheitlicher Cooldown-Zeitplan für Timeout- und Rate-Limit-Gründe |
🧠 Ursache
Architekturanalyse
Die Auth-Profile-Failover-Schleife im eingebetteten Runner vermischt zwei unterschiedliche Fehlermodi:
- Starke Rate-Limit-Signale: HTTP 429, anbieterspezifische Fehlercodes (z.B.
error.code === "rate_limit_exceeded") - Schwache transiente Signale: Generische Anfrage-Zeitüberschreitungen (Netzwerkaussetzer, langsames Streaming, SDK-Latenzspitzen)
Code-Pfad-Aufschlüsselung
Datei: src/agents/pi-embedded-runner/run.ts
Der Timeout-Handler führt ohne Wiederholungs-Gate aus:
typescript // Vereinfachter Ablauf (Zeilennummern approximativ) async function executeWithAuthProfile(provider, profile, request) { try { const result = await executeRequest(request, { timeout: timeoutMs }); return result; } catch (error) { if (isTimeout(error)) { // ❌ Kein Retry-Gate - sofortige Fehlermarkierung markAuthProfileFailure(profile, { reason: “timeout” }); advanceAuthProfile(provider); // ← Löst Rotation aus throw new NoAvailableAuthProfileError(provider); }
if (isRateLimit(error)) {
// ✓ Korrekt: starkes Signal rechtfertigt sofortigen Cooldown
markAuthProfileFailure(profile, { reason: "rate_limit" });
advanceAuthProfile(provider);
throw new NoAvailableAuthProfileError(provider);
}
} }
Datei: src/agents/auth-profiles/usage.ts
Die Cooldown-Berechnung wendet identische exponentielle Zeitpläne für alle Fehlergründe an:
typescript function calculateAuthProfileCooldownMs(errorCount: number): number { // ~1m → 5m → 25m → 1h Deckelung const baseMs = 60_000; const cooldown = baseMs * Math.pow(5, Math.min(errorCount - 1, 3)); return Math.min(cooldown, 3_600_000); // 1-Stunden-Deckelung }
// Identisch aufgerufen für “timeout” und “rate_limit” Gründe
Fehlerkaskaden-Sequenz
- Anfrage-Zeitüberschreitung tritt auf
- markAuthProfileFailure(reason: “timeout”) schreibt Cooldown-Eintrag
- advanceAuthProfile() rotiert zum nächsten Profil
- Wenn alle Profile nicht verfügbar: a. NoAvailableAuthProfileError wird ausgelöst b. Überprüfe agents.defaults.model.fallbacks c. Fortfahren zum Fallback-Modell/Anbieter ← Verfrüht!
- Wenn keine Fallbacks: Anfrage schlägt vollständig fehl
Warum dies falsch ist
| Signaltyp | Zuverlässigkeit | Angemessene Reaktion |
|---|---|---|
| HTTP 429 | Hoch | Sofortiger Cooldown + Rotation |
| Anbieter-Fehlercode | Hoch | Sofortiger Cooldown + Rotation |
| Generischer Timeout | Niedrig (transient) | Wiederholung mit Backoff vor Cooldown |
Generische Zeitüberschreitungen sind nicht unterscheidbar von:
- Temporären Netzwerk-Latenzspitzen
- Langsamer Streaming-Antwort-Initiation
- SDK-Verbindungsoverhead
- Temporärer anbieterspezifischer Last
Konfigurationslücke
Es existiert keine Konfiguration zur Steuerung des Wiederholungsverhaltens pro Grund:
typescript // Aktuell: Kein retrySameProfileOnTimeout Config vorhanden agents: { defaults: { timeoutSeconds: 30, modelFailover: { // Fehlend: retrySameProfileOnTimeout, retryBackoffMs } } }
🛠️ Schritt-für-Schritt-Lösung
Empfohlen: Minimale Retry-Gate-Ergänzung
Diese Lösung fügt ein pro-Grund-Retry-Gate für Timeout-Fehler hinzu, bevor Cooldown und Rotation ausgelöst werden.
Schritt 1: Konfigurationsschema erweitern
Datei: src/config/schema.ts
Neue Felder zur Modell-Failover-Konfiguration hinzufügen:
typescript // Vorher interface ModelFailoverConfig { fallbacks: string[]; }
// Nachher interface ModelFailoverConfig { fallbacks: string[]; retrySameProfileOnTimeout: number; // Standard: 1 retryBackoffMs: [number, number]; // Standard: [300, 1200] ms (min, max jitter) }
Schritt 2: Retry-Gate im Embedded Runner implementieren
Datei: src/agents/pi-embedded-runner/run.ts
Timeout-Handling mit Wiederholungslogik modifizieren:
typescript // Vorher 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 } }
// Nachher async function executeWithAuthProfile(provider, profile, request, options = {}) { const config = getConfig(); const { retrySameProfileOnTimeout = 1, retryBackoffMs = [300, 1200] } = config.agents?.defaults?.modelFailover ?? {};
// Wiederholungen pro Profil pro Sitzung verfolgen 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) {
// Dasselbe Profil mit gejittertem Backoff wiederholen
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);
// Erneut auf demselben Profil ausführen (kein Cooldown geschrieben)
return await executeWithAuthProfile(
provider, profile, request,
{ ...options, isRetry: true }
);
}
// Wiederholungen erschöpft: Cooldown anwenden + rotieren
console.log(
`Profile ${profile.id} timed out (${maxRetries} retries exhausted). ` +
`Trying next account...`
);
markAuthProfileFailure(profile, { reason: "timeout" });
clearRetryState(profile.id); // Retry-Zähler zurücksetzen
advanceAuthProfile(provider);
throw new NoAvailableAuthProfileError(provider);
}
// Rate-Limit-Handling unverändert (sofortiger Cooldown)
if (isRateLimit(error)) {
markAuthProfileFailure(profile, { reason: "rate_limit" });
clearRetryState(profile.id);
advanceAuthProfile(provider);
throw new NoAvailableAuthProfileError(provider);
}
throw error;
} }
Schritt 3: Retry-Zustandsverwaltung hinzufügen
Datei: src/agents/auth-profiles/retry-state.ts (neue Datei)
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(); }
Schritt 4: Standardkonfiguration aktualisieren
Datei: src/config/defaults.ts
typescript // Vorher export const defaultAgentsConfig = { defaults: { timeoutSeconds: 30, modelFailover: { fallbacks: [] } } };
// Nachher export const defaultAgentsConfig = { defaults: { timeoutSeconds: 30, modelFailover: { fallbacks: [], retrySameProfileOnTimeout: 1, retryBackoffMs: [300, 1200] } } };
Konfiguration nach der Lösung
json5 { “agents”: { “defaults”: { “timeoutSeconds”: 30, “modelFailover”: { “fallbacks”: [“gpt-4-turbo”, “claude-3-opus”], “retrySameProfileOnTimeout”: 1, // Wiederholungen vor Cooldown (0 = deaktiviert) “retryBackoffMs”: [300, 1200] // [min, max] gejitterte Verzögerung in ms } } } }
Optional: Pro-Grund-Cooldown-Zeitpläne
Für eine ausgereiftere Lösung, Cooldown-Zeitpläne nach Grund differenzieren:
Datei: src/agents/auth-profiles/usage.ts
typescript const COOLDOWN_SCHEDULES = { timeout: { baseMs: 10_000, // 10 Sekunden (vs 60s für Rate-Limit) multiplier: 2, // 10s → 20s → 40s → 80s capMs: 300_000 // 5 Minuten Deckelung (vs 1 Stunde) }, rate_limit: { baseMs: 60_000, multiplier: 5, // 60s → 5m → 25m → 1h capMs: 3_600_000 // 1 Stunde Deckelung } };
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); }
🧪 Verifizierung
Unit-Test: Einzelne Zeitüberschreitung wiederholt dasselbe Profil
Datei: 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] }
});
// Verify retry occurred
expect(executeRequest).toHaveBeenCalledTimes(2);
// Verify NO cooldown was written
expect(markAuthProfileFailure).not.toHaveBeenCalled();
// Verify NO rotation occurred
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);
// Verify retry exhausted
expect(executeRequest).toHaveBeenCalledTimes(2);
// Verify cooldown WAS written
expect(markAuthProfileFailure).toHaveBeenCalledWith(
mockProfile,
{ reason: 'timeout' }
);
// Verify rotation occurred
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);
// Verify NO retry for rate-limit
expect(executeRequest).toHaveBeenCalledTimes(1);
expect(markAuthProfileFailure).toHaveBeenCalledWith(
mockProfile,
{ reason: 'rate_limit' }
);
}); });
Integrationstest: Mehrere Profile + Intermittierende Zeitüberschreitungen
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] } });
// Should succeed using profile-1 or profile-3 expect(result).toBeDefined();
// profile-2 cooldown should be recorded expect(getProfileCooldown(‘profile-2’)).toBeDefined(); expect(getProfileCooldown(‘profile-3’)).toBeUndefined(); });
Manuelle Verifizierungsschritte
bash
1. Debug-Logging aktivieren
export OPENCLAW_LOG_LEVEL=debug
2. Agent mit bekanntem timeout-anfälligen Szenario ausführen
openclaw run –agent ./test-agent.ts –timeout-seconds 5
3. Erwartete Log-Ausgabe mit Lösung:
[DEBUG] Profile openai-codex:default timed out. Retry 1/1 in 847ms…
[DEBUG] Request succeeded on retry
NICHT: “Trying next account…” bei erster Zeitüberschreitung
4. Nach Lösung, wenn Wiederholungen erschöpft:
[INFO] Profile openai-codex:default timed out (1 retries exhausted). Trying next account…
[INFO] Cooldown applied: 10000ms for timeout reason
Verifizierungs-Checkliste
| Kriterium | Testmethode | Erwartetes Ergebnis |
|---|---|---|
| Einzelne Zeitüberschreitung wiederholt dasselbe Profil | Unit-Test | 2 executeRequest-Aufrufe, 0 Cooldown-Schreibvorgänge |
| Erschöpfte Wiederholungen → Cooldown | Unit-Test | markAuthProfileFailure aufgerufen mit reason: “timeout” |
| Rate-Limit umgeht Wiederholung | Unit-Test | 1 executeRequest-Aufruf, sofortiger Cooldown |
| Korrekte Log-Ausgabe | Manuelle Prüfung | Retry-Zähler + Verzögerung vor Cooldown angezeigt |
| Verhinderung der Profilerschöpfung | Integrationstest | 3 intermittierende Zeitüberschreitungen verwenden maximal 2 Profile |
⚠️ Häufige Fehler
Randfälle und umgebungsspezifische Fallen
- Jitter-Bereich zu eng: Wenn
retryBackoffMszu klein ist (z.B.[1, 10]), können Wiederholungen sofort auf dasselbe transiente Problem treffen. Empfohlenes Minimum:[300, 1200] - Risiko endloser Wiederholungsschleife: Wenn
retrySameProfileOnTimeoutsehr hoch gesetzt ist ohne globales Timeout, können Anfragen unbegrenzt hängen. Immer mittimeoutSecondskombinieren - Retry-Zustands-Leckage zwischen Sitzungen: Sicherstellen, dass
clearRetryState()bei erfolgreicher Profilrotation aufgerufen wird, um veraltete Retry-Zähler zu verhindern - Speicherdruck bei langlebigen Prozessen: Die Retry-Zustandsmap sollte WeakMap oder explizite Bereinigung für Profilobjekte verwenden
macOS-spezifische Überlegungen
bash
Die Simulation der Netzwerklatenz kann abweichen
Testen mit: sudo scutil –set InitialTSR 5000
Docker-spezifische Überlegungen
bash
Container-Netzwerk-Timeouts können je nach Ressourcenbeschränkungen variieren
Sicherstellen, dass der Container ausreichende Ressourcen für das Timeout-Handling hat:
docker run –memory=512m –cpus=1 …
Windows-spezifische Überlegungen
powershell
PowerShell-Schlafpräzision unterscheidet sich von Unix
Sicherstellen, dass die Sleep-Implementierung eine Monotonic-Clock verwendet:
[System.Diagnostics.Stopwatch]::GetTimestamp()
Konfigurationsfallen
typescript // ❌ FALSCH: retryBackoffMs umgekehrt (min > max) { retryBackoffMs: [1200, 300] }
// ✅ KORREKT: [min, max] { retryBackoffMs: [300, 1200] }
// ❌ FALSCH: retrySameProfileOnTimeout = 0 deaktiviert das gesamte Timeout-Handling // (sollte “Wiederholung bei Timeout deaktiviert” sein, nicht “unendliche Wiederholungen”) { retrySameProfileOnTimeout: 0 }
// ✅ KORREKT: Zum Deaktivieren einen großen Backoff oder separate Konfiguration verwenden { retrySameProfileOnTimeout: 0, timeoutRetriesEnabled: false }
Wechselwirkung mit bestehendem Fallback-Verhalten
Wenn agents.defaults.model.fallbacks konfiguriert ist, gilt das Wiederholungsverhalten pro Anbieter:
json5 { “agents”: { “defaults”: { “modelFailover”: { “fallbacks”: [“gpt-4”, “claude-3”], “retrySameProfileOnTimeout”: 1, “retryBackoffMs”: [500, 2000] } } } }
Sequenz mit Lösung:
- Anfrage an
gpt-4-turbomitopenai-codex:profile-1führt zu Zeitüberschreitung - Dasselbe profile-1 wiederholen (kein Cooldown geschrieben)
- Wiederholung schlägt fehl → Cooldown + Rotation zu
profile-2 - Wenn profile-2 ebenfalls erschöpft → Fallback auf
gpt-4(frische Profile)
🔗 Zugehörige Fehler
Kontextuell verbundene Fehlercodes und historische Probleme
| Fehler / Problem | Beschreibung | Beziehung |
|---|---|---|
NoAvailableAuthProfileError | Wird ausgelöst, wenn alle Profile im Cooldown sind | Primäres Symptom des aggressiven Timeout-Handlings |
Profile ${id} timed out (possible rate limit) | Irreführende Log-Meldung | Impliziert Rate-Limit, obwohl nur Timeout aufgetreten ist |
MARK_AUTH_PROFILE_FAILURE | Auth-Profil-Fehlerverfolgung | Kernmechanismus, der ein Retry-Gate benötigt |
| HTTP 429 | Explizites Rate-Limit-Signal | Korrekter Auslöser für Cooldown (sollte unverändert bleiben) |
error.code === “insufficient_quota” | Anbieterspezifischer Quotenfehler | Starkes Signal, sollte Wiederholung umgehen |
Zugehörige Konfigurationsparameter
| Parameter | Aktuelles Verhalten | Problem |
|---|---|---|
agents.defaults.timeoutSeconds | Löst Profilrotation aus | Zu aggressiv für transiente Zeitüberschreitungen |
agents.defaults.modelFailover.fallbacks | Wird ausgelöst, wenn alle Profile erschöpft sind | Unnötigerweise durch einzelne Zeitüberschreitung ausgelöst |
agents.defaults.maxConcurrentRequests | Kann Timeout-Probleme verschlimmern | Hohe Parallelität + Timeouts = schnellere Profilerschöpfung |
Historischer Kontext
Dieses Problem manifestiert sich je nach Konfiguration unterschiedlich:
- High-Traffic-Deployments: Mehrere gleichzeitige Zeitüberschreitungen können alle Profile schnell erschöpfen
- Low-Traffic-Deployments: Einzelne Zeitüberschreitung kann das einzige Signal sein, verursacht aber trotzdem Fallback
- Gemeinsame Infrastruktur: Timeouts eines Teams beeinflussen die Profilverfügbarkeit anderer Teams
Verweise auf zugehörige OpenClaw-Komponenten
src/agents/pi-embedded-runner/run.ts- Embedded-Runner-Auth-Schleifesrc/agents/auth-profiles/usage.ts- Cooldown-Berechnungsrc/agents/auth-profiles/cooldown-store.ts- Persistenter Cooldown-Zustandsrc/config/schema.ts- Konfigurationstyp-Definitionensrc/errors/auth-profile-errors.ts- Fehlerklassendefinitionen