fix(prompt-eval): rate-limit aware backoff, circuit breaker and process-group termination - #637
fix(prompt-eval): rate-limit aware backoff, circuit breaker and process-group termination#637NY1024 wants to merge 2 commits into
Conversation
…ss-group termination Fixes Tencent#633 Problem: during prompt security evaluation, when the target model hits its QPM limit, requests surge far beyond the configured concurrency (80+/min against a QPM=20 quota) and keep firing even after the task is stopped. Root causes (3): 1. OpenaiAlikeModel.generate/a_generate retried any error (incl. 429) with a short fixed backoff (0.5s base) and up to 3 tries, then returned "" silently. Failures cascaded: each failing task finished fast and let the next one fire immediately, creating a request storm that never let the quota recover. 2. The attack-generation phase (a_simulate baseline stage) spawned all vulnerabilities concurrently without any semaphore, unlike the enhance stage, so simulator/model traffic could burst. 3. On task stop, exec.CommandContext only SIGKILLed the direct child (uv); the Python cli_run.py process survived as an orphan and kept sending requests. Changes: - cli/model_utils/openailike.py: * detect rate-limit/quota errors (429, "rate limit", "too many requests", ...) via message markers, independent of exception type * honor Retry-After when present; otherwise back off 8s/16s/32s (capped at 60s) for rate-limit errors; non-rate errors keep the original 1s-base exponential backoff; max_trial 3 -> 5 * track consecutive rate-limit failures per model instance - deepteam/red_teamer/red_teamer.py (_a_attack): * circuit breaker: skip remaining test cases once the target model accumulates 3 consecutive rate-limit failures; reset on success - deepteam/attacks/attack_simulator/attack_simulator.py (a_simulate): * wrap baseline attack simulation with the shared semaphore so the generation stage respects max_concurrent - common/utils/utils.go + utils_unix.go/utils_windows.go: * run subprocess in its own process group (unix) and kill the whole group on context cancellation, so stopping a task terminates uv and its Python children; windows keeps exec default behavior Testing: - unit tests: rate-limit detection, Retry-After extraction, backoff schedule, breaker counter reset (all pass) - e2e simulation: 40 tasks against a QPM=20 gateway -> 40/40 completed, only 4 rejections at startup window warm-up - process-group test: cancelling RunCmdWithContext kills sh + child sleep processes; no orphans remain
boy-hack
left a comment
There was a problem hiding this comment.
Thanks @NY1024 — this is a thorough, well-diagnosed fix and the problem description (request storm + orphaned Python process) matches what I'd expect from a QPM-throttled target. The process-group kill in Go and the semaphore around the baseline stage are both correct. A few notes before merge:
Bug: the circuit breaker never trips
The rate-limit counter is incremented on the OpenaiAlikeModel instance (self._consecutive_rate_limit_failures += 1 in openailike.py), but the circuit breaker in RedTeamer._a_attack reads a different counter — one stored on the RedTeamer instance (getattr(self, "_consecutive_rate_limit_failures", 0)). The RedTeamer counter is only ever reset to 0 on success and is never incremented anywhere, so consecutive_failures is always 0 and the breaker can never trigger. The two need to share state: either expose the counter from the model instance (e.g. model_callback wrapping the model, or have _a_attack read it off the model object), or move the increment into _a_attack itself.
Minor / non-blocking
_retry_after_secondsreadsgetattr(exc, "retry_after", None)— the OpenAI SDK puts that on the exception'sresponse/headers, not as a top-level attribute, so it'll almost always fall back to the computed schedule. Worth confirming against the SDK version you're pinning; if it's never populated, a plainmin(schedule, max_wait_seconds)is clearer.RATE_LIMIT_MARKERSincludes the bare substring"429"and"qpm"; a benign error message containing "429" anywhere (e.g. a request ID) would be misclassified as rate-limited and get the long backoff. Low risk, but substring matching on digits is a little loose.- On Windows,
setProcessGroup/killProcessGroupare no-ops, so the orphan-process problem remains on that platform. Fine if Windows isn't a supported eval target, but worth a one-line note in the PR/README.
Once the counter sharing is fixed (and ideally a unit test asserting the breaker trips after N consecutive 429s), this is good to go. Nice work on the Go side especially.
… harden Retry-After parsing Address review feedback on PR Tencent#637. Bug: the circuit breaker never tripped The rate-limit failure counter was incremented on the OpenaiAlikeModel instance but read from the RedTeamer instance, so the breaker always saw 0. Now the counter lives on the model instance (thread-safe via note/get/reset_rate_limit_failures) and the breaker reads it through model_callback.__self__, sharing the same state as the retry loop. Falls back to a no-op when the callback is not a bound method. Retry-After extraction The OpenAI SDK (1.x/3.x) exposes Retry-After on exc.response.headers, not as a top-level exception attribute. Parse response headers (case-insensitive, incl. Retry-After-Ms and x-ratelimit-reset-requests), then the retry_after attribute, then "retry after N seconds" message text; ignore absurd values (>600s). Rate-limit markers Drop the bare "429"/"qpm" substrings that misclassified benign messages (e.g. request IDs containing 429). Match explicit phrases plus a standalone "429" token via word boundaries, so req_429ab3c or 4290ms no longer trigger the long backoff. Tests & docs Add AIG-PromptSecurity/tests/test_rate_limit_and_breaker.py with 24 cases: marker detection, Retry-After sources, backoff schedules, counter accumulation/reset, breaker trips after N consecutive 429s, breaker reset on success, and graceful degradation without a model instance. Document rate-limit behavior and the Windows process-termination limitation in the AIG-PromptSecurity README. Narrow the root .gitignore "tests" rule to /tests so subproject test directories are tracked.
boy-hack
left a comment
There was a problem hiding this comment.
Thanks @NY1024 — this is a thorough, well-reasoned fix and the marker-based rate-limit detection (rather than exception-type based) is the right call for OpenAI-compatible gateways that raise heterogeneous errors. The unit test suite for _is_rate_limit_error / _retry_after_seconds / breaker shared-state is genuinely good coverage.
A few points to confirm / consider (none blocking):
-
Process-group termination (
utils_unix.go) — TheSetpgid: true+syscall.Kill(-pid, SIGKILL)approach is correct for killing theuv→Python orphan tree on Linux/macOS. Two small notes:cmd.Cancelnow does bothkillProcessGroupandcmd.Process.Kill(). Since the child is in its own group,Kill()on the direct child is redundant but harmless; fine.cmd.WaitDelay = 5 * time.Secondmeans after context cancellation the command can linger up to 5s while stdio drains. For a "stop task" UX that's a ~5s tail where the UI may still show running — acceptable, just flagging so it's intentional.- Windows degrades to
exec.CommandContextdefault (only direct child) — you've documented this in README and the code comment, so no surprise there.
-
Circuit-breaker coupling via
model_callback.__self__— This is elegant but fragile: it only works if the callback is a bound method of theOpenaiAlikeModelinstance. If anyone wraps it (e.g.functools.partial, a lambda, or an adapter),__self__resolves to the wrapper and the breaker silently no-ops (returns 0) — it degrades gracefully (no crash), but the protection would silently vanish. A short comment near_get_consecutive_rate_limit_failuresdocumenting the "must be a bound method of the model instance" contract would prevent a future regression. -
.gitignorechange — Switchingtests→/testssoAIG-PromptSecurity/tests/is now tracked is the right move (it lets the new test file live there). Just double-check no large/fixture-ladentests/dir gets pulled into the repo unexpectedly. -
CI is showing
pendingat the head commit — can you confirmgo build ./common/...+go test ./common/utils/and the Pythonpytest tests/actually pass in CI (rather than just locally)? The Go process-group kill is the kind of thing that's easy to verify locally but needs the CI job to actually run it.
Overall this is solid and ready once CI is green and point 2's contract is documented. Nice work on the request-storm root cause analysis.
|
Thanks for this — it's a well-scoped fix for a real problem (the request storm during rate limiting is nasty), and the design is sound. A few notes before merge: Strengths
Minor / needs confirmation
None of these block merge. Net: approve with the (Review comment only — not merging.) |
Fixes #633
Problem
During prompt security evaluation, once the target model hits its QPM limit, request frequency surges far beyond the configured concurrency (80+ requests/min against a QPM=20 quota, even with concurrency set to 1), and requests keep being sent for a while after the task is stopped.
Root Causes
1. Blind short-backoff retry on rate-limit errors (
AIG-PromptSecurity/cli/model_utils/openailike.py)generate()/a_generate()retried any error (including 429) with a short fixed backoff (0.5s base, 3 tries), then returned""silently. Each failing task finished within ~3.5s, letting the next test case fire immediately — a request storm that never gave the quota window a chance to recover. This also explains why fast-failing models (Minimax-M2.5, Deepseek-V4-Flash, Qwen3.6/3.8) triggered the storm while slower ones (Qwen3-30B-A3B-2507) mostly survived: the faster the failure cycle, the faster the request rate.2. Unbounded concurrency in the attack-generation phase (
deepteam/attacks/attack_simulator/attack_simulator.py)In
a_simulate(), the baseline stage spawned all vulnerabilities concurrently without the shared semaphore (only the enhance stage had it), so simulator traffic could burst beyondmax_concurrent.3. Orphaned Python process after task stop (
common/utils/utils.go)Stopping a task SIGKILLed only the direct child process (
uv) viaexec.CommandContext; the actual Pythoncli_run.pyprocess survived as an orphan and kept sending requests until its queue drained.Changes
cli/model_utils/openailike.pyRetry-Afterwhen present, otherwise back off 8s/16s/32s (cap 60s); non-rate errors keep original backoff;max_trial3→5; track consecutive rate-limit failures per modeldeepteam/red_teamer/red_teamer.py_a_attack: skip remaining test cases once the target model accumulates 3 consecutive rate-limit failures (reset on success), instead of hammering a throttled modeldeepteam/attacks/attack_simulator/attack_simulator.pymax_concurrentcommon/utils/utils.go+utils_unix.go/utils_windows.gouvand its Python children together. Windows keeps exec default behavior (platform files are build-tag separated)Testing
Retry-Afterextraction, backoff schedule (8s-base exponential for rate limits, 1s for other errors), failure counter reset on success — all passRunCmdWithContexton ashscript that spawns a childsleep 300→ both parent and child killed, no orphan processes remaingo build ./common/...andgo test ./common/utils/passNotes