+- **Synchronous in-memory failover (I1):** health is updated in-process before the response returns (`MarkCooldown` write lock, `ResolveActive` read lock) so the switch never waits on the 2s watcher (which only reconciles); a detached `onFailover` also writes `SetCredentialHealth(member,'cooldown',now+ttl,reason)` for durability. **Cooldown window (B1):** `cooldownFromResponse(class, f.Response.Header, f.Response.Body)` (`internal/proxy/pool_failover.go`) derives the TTL from the upstream recovery hints, spanning the conventions across AI providers and general rate limiters. Headers are tried in precedence order (first usable wins; `Retry-After` MUST be first per IETF draft-ietf-httpapi-ratelimit-headers): `Retry-After` (RFC 9110 delta-seconds or HTTP-date), `RateLimit-Reset` (IETF draft, delta-seconds), `X-RateLimit-Reset` (generic; GitHub/Twitter unix epoch, others delta), `X-RateLimit-Reset-After` (Discord, delta float), OpenAI `x-ratelimit-reset-requests` / `x-ratelimit-reset-tokens` (unit-suffixed durations), Anthropic `anthropic-ratelimit-requests-reset` / `anthropic-ratelimit-tokens-reset` (RFC3339/ISO-8601 absolute timestamp). Each value parses as delta-seconds, a unix epoch (magnitude-disambiguated at `epochThreshold` ~2001), an HTTP-date (Retry-After only), an RFC3339/ISO-8601 timestamp, or a unit-suffixed duration; clamped to `[floor(class), vault.MaxCooldown=6h]`. When NO usable header is present it parses the JSON body (`parseBodyRecoveryHint`, skipped if empty or >64 KiB) for `resets_in_seconds`, `resets_at`, `retry_after`, or `reset_after` — probed top-level FIRST then nested under a top-level `error` object — covering OpenAI Codex `usage_limit_reached` (`resets_in_seconds`/`resets_at`) and the Discord/generic `retry_after`/`reset_after` conventions. Each body value uses the same epoch-vs-delta disambiguation as the headers; the body reset is authoritative and can be hours/days out, so a body-derived window is clamped to the higher `[floor(class), vault.MaxUsageLimitCooldown=24h]`. Codex usage-limit 429s carry no recovery header, so without the body fallback the pool re-probes an account that won't reset for hours every 60s (the degrade-flap root cause). Neither hint falls back to the class default (`vault.RateLimitCooldown`=60s / `vault.AuthFailCooldown`=300s). Floors: rate-limit `vault.MinRateLimitFloor`=10s (a short parsed window is honored, not floored up to 60s), auth-failure `AuthFailCooldown` (a revoked/expired token is never re-probed in seconds). **Cooldown extension is monotonic on both layers:** a member parked 300s for auth that then trips a 60s rate-limit keeps the LATER expiry — `MarkCooldown` and `SetCredentialHealth`'s `cooldown` upsert (CASE-compared against the stored future `cooldown_until`) both keep `max(existing-future, new)`. Only extend is monotonic: an explicit clear (zero/past `until`) and any transition to `healthy` still shorten/clear, and lazy expiry still wins over an expired stored cooldown. No in-flight retry — next request uses the new member.
0 commit comments