mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-06 23:32:12 +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>
73 lines
2.9 KiB
TypeScript
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`);
|
|
});
|