Rotación de Auth Basada en Timeout Activa Prematuramente Provider Fallback
Los timeouts de solicitud genéricos son incorrectamente tratados como señales de rate-limit, causando enfriamiento y rotación agresiva de perfil de auth que se propaga a provider/model fallback incluso cuando el provider está temporalmente lento.
🔍 Síntomas
Mensajes de error principales
Cuando se produce un timeout de solicitud en un proveedor que soporta auth.profiles, el runner embebido emite mensajes de error en cascada:
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
Comportamiento observable
- Agotamiento prematuro de perfiles: Un solo timeout en un perfil causa rotación inmediata al siguiente perfil disponible
- Acumulación de estados de cooldown: Cada timeout escribe una entrada de cooldown con backoff exponencial (~1m → 5m → 25m → 1h cap)
- Fallback de modelo innecesario: Cuando todos los perfiles entran en cooldown, el sistema procede a los fallbacks de modelo configurados incluso si el proveedor original está operativo
- Ruido en logs: Mensajes repetidos de `timed out (possible rate limit)` crean confusión sobre el estado real de rate-limiting
Escenario de reproducción
bash
Activador: Una única solicitud supera el umbral de timeoutSeconds
openclaw run –agent ./my-agent.ts –timeout-seconds 30
Observado: Rotación inmediata de perfil de autenticación sin reintento
Esperado: Al menos un reintento con backoff antes de la rotación
Componentes afectados
| Componente | Ruta del archivo | Punto de falla |
|---|---|---|
| Embedded Runner | src/agents/pi-embedded-runner/run.ts | Timeout → markAuthProfileFailure() → advanceAuthProfile() |
| Auth Profiles | src/agents/auth-profiles/usage.ts | Programación uniforme de cooldown para timeout y razones de rate-limit |
🧠 Causa raíz
Análisis arquitectónico
El bucle de failover de auth-profile en el runner embebido confunde dos modos de falla distintos:
- Señales fuertes de rate-limit: HTTP 429, códigos de error específicos del proveedor (ej.,
error.code === "rate_limit_exceeded") - Señales transitorias débiles: Timeouts genéricos de solicitudes (latencia de red temporal, streaming lento, pico de latencia del SDK)
Desglose de la ruta del código
Archivo: src/agents/pi-embedded-runner/run.ts
El handler de timeout se ejecuta sin una compuerta de reintento:
typescript // Flujo simplificado (números de línea aproximados) async function executeWithAuthProfile(provider, profile, request) { try { const result = await executeRequest(request, { timeout: timeoutMs }); return result; } catch (error) { if (isTimeout(error)) { // ❌ Sin compuerta de reintento - marcado de falla inmediato markAuthProfileFailure(profile, { reason: “timeout” }); advanceAuthProfile(provider); // ← Dispara rotación throw new NoAvailableAuthProfileError(provider); }
if (isRateLimit(error)) {
// ✓ Correcto: señal fuerte justifica cooldown inmediato
markAuthProfileFailure(profile, { reason: "rate_limit" });
advanceAuthProfile(provider);
throw new NoAvailableAuthProfileError(provider);
}
} }
Archivo: src/agents/auth-profiles/usage.ts
El cálculo de cooldown aplica una programación exponencial idéntica para todas las razones de falla:
typescript function calculateAuthProfileCooldownMs(errorCount: number): number { // ~1m → 5m → 25m → 1h cap const baseMs = 60_000; const cooldown = baseMs * Math.pow(5, Math.min(errorCount - 1, 3)); return Math.min(cooldown, 3_600_000); // 1-hour cap }
// Llamado de forma idéntica para razones “timeout” y “rate_limit”
Secuencia de cascada de fallas
- Ocurre timeout de solicitud
- markAuthProfileFailure(reason: “timeout”) escribe entrada de cooldown
- advanceAuthProfile() rota al siguiente perfil
- Si todos los perfiles no están disponibles: a. Se lanza NoAvailableAuthProfileError b. Se verifican agents.defaults.model.fallbacks c. Se procede al fallback de modelo/proveedor ← ¡Prematuro!
- Si no hay fallbacks: La solicitud falla completamente
Por qué esto es incorrecto
| Tipo de señal | Confiabilidad | Respuesta apropiada |
|---|---|---|
| HTTP 429 | Alta | Cooldown inmediato + rotar |
| Código de error del proveedor | Alta | Cooldown inmediato + rotar |
| Timeout genérico | Baja (transitoria) | Reintentar con backoff antes del cooldown |
Los timeouts genéricos son indistinguibles de:
- Picos temporales de latencia de red
- Inicio lento de respuesta de streaming
- Sobrecarga de conexión del SDK
- Carga temporal del lado del proveedor
Brecha de configuración
No existe configuración para controlar el comportamiento de reintento por razón:
typescript // Actual: No existe config retrySameProfileOnTimeout agents: { defaults: { timeoutSeconds: 30, modelFailover: { // Faltante: retrySameProfileOnTimeout, retryBackoffMs } } }
🛠️ Solución paso a paso
Recomendado: Adición de compuerta de reintento mínima
Esta solución agrega una compuerta de reintento por razón para las fallas de timeout antes de activar el cooldown y la rotación.
Paso 1: Extender el esquema de configuración
Archivo: src/config/schema.ts
Agregar nuevos campos a la configuración de failover de modelo:
typescript // Antes interface ModelFailoverConfig { fallbacks: string[]; }
// Después interface ModelFailoverConfig { fallbacks: string[]; retrySameProfileOnTimeout: number; // Por defecto: 1 retryBackoffMs: [number, number]; // Por defecto: [300, 1200] ms (mín, máx jitter) }
Paso 2: Implementar compuerta de reintento en el Runner Embebido
Archivo: src/agents/pi-embedded-runner/run.ts
Modificar el manejo de timeout para incluir lógica de reintento:
typescript // Antes 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); } // … manejo de rate limit } }
// Después async function executeWithAuthProfile(provider, profile, request, options = {}) { const config = getConfig(); const { retrySameProfileOnTimeout = 1, retryBackoffMs = [300, 1200] } = config.agents?.defaults?.modelFailover ?? {};
// Seguimiento de reintentos por perfil por sesión 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) {
// Reintentar en el mismo perfil con backoff con jitter
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);
// Re-ejecutar en el mismo perfil (sin escribir cooldown)
return await executeWithAuthProfile(
provider, profile, request,
{ ...options, isRetry: true }
);
}
// Reintentos agotados: aplicar cooldown + rotar
console.log(
`Profile ${profile.id} timed out (${maxRetries} retries exhausted). ` +
`Trying next account...`
);
markAuthProfileFailure(profile, { reason: "timeout" });
clearRetryState(profile.id); // Reiniciar contador de reintentos
advanceAuthProfile(provider);
throw new NoAvailableAuthProfileError(provider);
}
// Manejo de rate-limit sin cambios (cooldown inmediato)
if (isRateLimit(error)) {
markAuthProfileFailure(profile, { reason: "rate_limit" });
clearRetryState(profile.id);
advanceAuthProfile(provider);
throw new NoAvailableAuthProfileError(provider);
}
throw error;
} }
Paso 3: Agregar gestión de estado de reintento
Archivo: src/agents/auth-profiles/retry-state.ts (nuevo archivo)
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