mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-15 19:52:50 +03:00
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>
77 lines
2.4 KiB
TypeScript
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();
|
|
}
|