Files
OmniRoute/tests/unit/wafRateLimit.test.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

73 lines
2.9 KiB
TypeScript

import test from "node:test";
import assert from "node:assert/strict";
import {
gateOutboundRequest,
configureWafRateLimit,
getWafRateLimitConfig,
resetWafRateLimit,
} from "../../open-sse/services/wafRateLimit.ts";
// #FIX: agentrouter.org's WAF is burst-sensitive. The gate must serialize
// outbound calls per bucket and hold for at least `minGapMs` between calls.
test("first call is immediate (no previous timestamp)", async () => {
resetWafRateLimit();
configureWafRateLimit({ minGapMs: 100 });
const t0 = Date.now();
await gateOutboundRequest("agentrouter:https://example.com/v1/messages");
const elapsed = Date.now() - t0;
assert.ok(elapsed < 50, `first call should be near-instant, was ${elapsed}ms`);
});
test("second call within minGapMs is throttled", async () => {
resetWafRateLimit();
configureWafRateLimit({ minGapMs: 300 });
await gateOutboundRequest("agentrouter:https://example.com/v1/messages");
const t0 = Date.now();
await gateOutboundRequest("agentrouter:https://example.com/v1/messages");
const elapsed = Date.now() - t0;
assert.ok(elapsed >= 250, `second call should wait at least minGapMs, was ${elapsed}ms`);
assert.ok(elapsed < 600, `second call should not wait much longer than minGapMs, was ${elapsed}ms`);
});
test("third call after the gate has been satisfied is not throttled", async () => {
resetWafRateLimit();
configureWafRateLimit({ minGapMs: 100 });
await gateOutboundRequest("agentrouter:https://example.com/v1/messages");
await new Promise((resolve) => setTimeout(resolve, 150));
const t0 = Date.now();
await gateOutboundRequest("agentrouter:https://example.com/v1/messages");
const elapsed = Date.now() - t0;
assert.ok(elapsed < 50, `third call after cooldown should be near-instant, was ${elapsed}ms`);
});
test("buckets are independent", async () => {
resetWafRateLimit();
configureWafRateLimit({ minGapMs: 500 });
await gateOutboundRequest("agentrouter:https://a.example.com/v1/messages");
const t0 = Date.now();
// Different bucket key → independent state → should not be throttled
await gateOutboundRequest("agentrouter:https://b.example.com/v1/messages");
const elapsed = Date.now() - t0;
assert.ok(elapsed < 50, `independent bucket should not be throttled, was ${elapsed}ms`);
});
test("configureWafRateLimit overrides defaults", () => {
resetWafRateLimit();
configureWafRateLimit({ minGapMs: 42 });
const cfg = getWafRateLimitConfig();
assert.equal(cfg.minGapMs, 42);
});
test("resetWafRateLimit clears all bucket state", async () => {
configureWafRateLimit({ minGapMs: 500 });
await gateOutboundRequest("agentrouter:https://example.com/v1/messages");
resetWafRateLimit();
// After reset, the next call should be near-instant
const t0 = Date.now();
await gateOutboundRequest("agentrouter:https://example.com/v1/messages");
const elapsed = Date.now() - t0;
assert.ok(elapsed < 50, `after reset, first call should be immediate, was ${elapsed}ms`);
});