Files
OmniRoute/open-sse/services/wafRateLimit.ts
Diego Rodrigues de Sa e Souza 7163081f5e fix(agentrouter): retry on 400 content-blocked + burst guard (#9323)
The agentrouter.org upstream WAF returns 400 content-blocked
intermittently when:
  1. messages[].content contains a blocked keyword (Lorem ipsum, the
     phrase 'language model' alone, 'virtual assistant', etc.); or
  2. Requests from the same IP/key arrive in a burst, after which the
     WAF's per-IP suspicion bucket starts blocking content that would
     normally pass. The bucket relaxes after ~5-10s of idle.

Apply three mitigations:

1. Burst guard (open-sse/services/wafRateLimit.ts)
   Per-bucket (provider+url) gate that enforces a 500ms minimum gap
   between outbound requests to agentrouter. Configurable via
   configureWafRateLimit(). Tested in tests/unit/wafRateLimit.test.ts.

2. Reactive retry (BaseExecutor.WAF_RETRY_CONFIG in base.ts)
   New WAF_RETRY_CONFIG with maxAttempts=2, delayMs=1500,
   backoffMultiplier=2. When the upstream returns 400 with a body that
   matches /content[_-]blocked/i, retry the same URL with exponential
   backoff (1.5s, 3.0s) before falling through to the 429/401/fallback
   chain. Tested in tests/unit/base-executor-waf-retry.test.ts.

3. Documentation (docs/security/AGENTROUTER_WAF.md)
   Blocklist of always-blocked and almost-always-blocked patterns,
   behavior under load, guidance for prompts/tool output, and pointers
   to the relevant code paths in OmniRoute.

These are belt-and-suspenders: the burst guard prevents the WAF from
activating on normal traffic, and the reactive retry recovers when it
does anyway. Together they should eliminate the intermittent
400 content-blocked that Claude Code sees when running through
agentrouter via OmniRoute.

Refs #9275 follow-up. Test: 'WAF retry config shape' and 'WAF retry
differs from generic' guard the WAF_RETRY_CONFIG contract so future
refactors don't accidentally collapse the two retry paths.

Co-authored-by: diegosouzapw <diegosouzapw@users.noreply.github.com>
2026-08-03 18:22:14 -03:00

77 lines
2.4 KiB
TypeScript

/**
* wafRateLimit.ts — Burst guard for agentrouter.org upstream WAF.
*
* The agentrouter.org gateway runs a content-filter WAF that becomes more
* aggressive after bursts of requests from the same IP/key, returning
* `400 content-blocked` for requests that would normally pass. After ~5-10
* seconds of cooldown the filter relaxes again.
*
* To avoid tripping the WAF, we serialize outbound calls per provider and
* enforce a minimum inter-request gap. The defaults are conservative and
* meant to be a safety net — the upstream request rate from Claude Code is
* inherently low (one human-paced request at a time), so this guard should
* not affect normal traffic.
*/
import { log } from "../utils/logger.ts";
interface BurstGuardState {
lastSentAt: number;
}
const state = new Map<string, BurstGuardState>();
export interface WafRateLimitConfig {
minGapMs: number;
}
const DEFAULT_CONFIG: WafRateLimitConfig = {
// 500ms is enough to prevent the burst-sensitive WAF from activating
// while staying well below human perception of latency.
minGapMs: 500,
};
let config: WafRateLimitConfig = { ...DEFAULT_CONFIG };
export function configureWafRateLimit(overrides: Partial<WafRateLimitConfig>): void {
config = { ...config, ...overrides };
}
export function getWafRateLimitConfig(): WafRateLimitConfig {
return { ...config };
}
/**
* Wait until at least `minGapMs` has passed since the last call to
* `gateOutboundRequest` for the same `bucketKey`. Safe to call from
* concurrent requests — the lock is held only for the sleep, not across
* the actual upstream fetch.
*
* @param bucketKey Stable identifier for the upstream (e.g. "agentrouter:url").
*/
export async function gateOutboundRequest(bucketKey: string): Promise<void> {
const now = Date.now();
const bucket = state.get(bucketKey);
if (!bucket) {
state.set(bucketKey, { lastSentAt: now });
return;
}
const elapsed = now - bucket.lastSentAt;
const wait = config.minGapMs - elapsed;
if (wait > 0) {
log?.debug?.(
"WAF_RATE_LIMIT",
`Throttling outbound to ${bucketKey} — waiting ${wait}ms (min gap ${config.minGapMs}ms)`
);
await new Promise((resolve) => setTimeout(resolve, wait));
}
state.set(bucketKey, { lastSentAt: Date.now() });
}
/**
* Reset all rate-limit state. Primarily for tests.
*/
export function resetWafRateLimit(): void {
state.clear();
}