Skip to content

feat(plugin): Add proactive rate limiting to prevent 429 errors - #14029

Open
LVT382009 wants to merge 4 commits into
NousResearch:mainfrom
LVT382009:pr-13936
Open

feat(plugin): Add proactive rate limiting to prevent 429 errors#14029
LVT382009 wants to merge 4 commits into
NousResearch:mainfrom
LVT382009:pr-13936

Conversation

@LVT382009

@LVT382009 LVT382009 commented Apr 22, 2026

Copy link
Copy Markdown
Contributor

Implements proactive rate limiting that tracks request counts per minute and blocks requests BEFORE they hit the API, preventing 429 errors and retry amplification. Provides runtime controls via slash commands to enable/disable the rate limiter and configure rate limits per provider.

Core Features

  • Proactive Rate Limiting: Tracks request counts per minute using sliding window and blocks requests BEFORE API calls
  • Cross-Session Tracking: Shared state file at $HERMES_HOME/rate_limits/ accessible by all Hermes sessions
  • Retry Amplification Prevention: Prevents up to 9 redundant API calls per 429 error (3 SDK retries × 3 Hermes retries)
  • Provider-Specific Limits: Configure different RPM limits per provider (nvidia, openrouter, nous, etc.)
  • Automatic Wait & Retry: When rate limit is reached, automatically waits and retries without user intervention
  • Plugin Hooks: pre_llm_call (inject context when rate-limited), post_llm_call (record 429 errors)
  • Runtime Controls: /ratelimit slash commands for status, clear, enable, disable, and set cooldown
  • Config Persistence: Enable/disable status, default cooldown, and per-provider limits stored in config file
  • Thread-Safe: Atomic writes using temp file + rename pattern for concurrent access

Configuration

  • Plugin is opt-in like other bundled plugins (disk-cleanup)
  • Config file at $HERMES_HOME/rate_limits/config.json stores:
    • enabled: Boolean - whether rate limiter is active (default: true)
    • default_cooldown: Float - default cooldown in seconds when 429 is hit (default: 300)
    • limits: Object - per-provider rate limits
      • default.requests_per_minute: Default RPM for all providers
      • {provider}.requests_per_minute: Provider-specific RPM (e.g., nvidia.requests_per_minute: 2)
  • State file at $HERMES_HOME/rate_limits/nous.json stores rate limit state from 429 errors
  • Tracker file at $HERMES_HOME/rate_limits/tracker_{provider}.json stores request timestamps for proactive limiting
  • State persists across sessions until manually cleared or expires

Integration

  • run_agent.py: Modified to call check_rate_limit_before_call() before making API requests
  • plugins/rate-limiter/plugin.yaml: Plugin manifest with hooks and commands (v2.0.0)
  • plugins/rate-limiter/rate_limiter.py: Core rate limiting logic with proactive blocking
  • plugins/rate-limiter/commands.py: Slash command handlers (status, clear, enable, disable, set)
  • plugins/rate-limiter/README.md: Plugin usage documentation

What does this PR do?

This PR adds proactive rate limiting that prevents 429 errors by tracking request counts per minute and blocking requests BEFORE they hit the API. When a configured RPM limit is reached, the plugin automatically waits and retries, eliminating retry amplification where each 429 error triggers up to 9 additional API calls.

Problem Solved: Side clients (CLI, gateway, cron jobs) hit rate limits and retry aggressively, causing up to 9x API call amplification. This wastes quota and can lead to further rate limit violations. The previous implementation only recorded 429 errors after they occurred, but couldn't prevent them.

Why This Approach:

  • Proactive blocking prevents 429 errors before they happen
  • Sliding window tracking ensures accurate per-minute rate limiting
  • Cross-session tracking ensures all Hermes processes respect rate limits
  • Plugin-based approach keeps core code clean and modular
  • Opt-in design follows existing bundled plugin patterns
  • Thread-safe implementation supports concurrent access
  • Runtime controls allow users to enable/disable and configure without editing config.yaml
  • Config persistence ensures settings survive across sessions

Key Changes from Original Implementation

  1. Proactive vs Reactive: Original implementation only recorded 429 errors after they occurred. New implementation tracks request counts and blocks BEFORE hitting the API.
  2. Sliding Window: Uses 1-minute sliding window instead of fixed minute boundaries for accurate rate limiting.
  3. Automatic Wait & Retry: When rate limit is reached, automatically waits and retries instead of just injecting context.
  4. Provider-Specific Limits: Supports different RPM limits per provider (nvidia, openrouter, nous, etc.).
  5. Modified run_agent.py: Added rate limit check in the retry loop before making API calls.

Related Issue

Prevents retry amplification for side clients when rate limits are hit.

Type of Change

  • ✨ New feature (non-breaking change that adds functionality)

Changes Made

  • run_agent.py (modified): Added check_rate_limit_before_call() call in retry loop before API requests
  • plugins/rate-limiter/rate_limiter.py (modified): Added check_rate_limit_before_call() function for proactive blocking
  • plugins/rate-limiter/plugin.yaml (modified): Updated to v2.0.0 with new description
  • plugins/rate-limiter/README.md (modified): Updated with proactive rate limiting behavior
  • plugins/rate-limiter/__init__.py (existing): Plugin registration with register() function and session hooks
  • plugins/rate-limiter/commands.py (existing): Slash commands for /ratelimit (status, clear, enable, disable, set)

How to Test

  1. Enable Plugin:

    hermes plugins enable rate-limiter
  2. Configure Rate Limit:

    # Set rate limit for nvidia provider to 2 requests per minute
    /ratelimit set nvidia 2
  3. Test Proactive Rate Limiting:

    # Send first request - should succeed
    hermes chat -q "What is 1+1?"
    
    # Send second request immediately - should succeed
    hermes chat -q "What is 2+2?"
    
    # Send third request immediately - should wait and retry
    hermes chat -q "What is 3+3?"
    # Expected: Shows "rate limit active - resets in Xs. Waiting..."
  4. Test Cross-Session Tracking:

    # Session 1: Check rate limit status
    /ratelimit status
    
    # Session 2: Check that state persists
    /ratelimit status
    # Expected: Same rate limit information visible
  5. Test Clear Command:

    /ratelimit clear
    /ratelimit status
    # Expected: No rate limit information
  6. Test Enable/Disable:

    /ratelimit disable
    /ratelimit status
    # Expected: Shows "✗ DISABLED"
    
    /ratelimit enable
    /ratelimit status
    # Expected: Shows "✓ ENABLED"
  7. Test Set Cooldown:

    /ratelimit set 600
    /ratelimit status
    # Expected: Shows "Default cooldown: 10m"

Checklist

Code

  • I've read the Contributing Guide
  • My commit messages follow Conventional Commits (fix(scope):, feat(scope):, etc.)
  • I searched for existing PRs to make sure this isn't a duplicate
  • My PR contains only changes related to this fix/feature (no unrelated commits)
  • I've run pytest tests/ -q and all tests pass
  • I've added tests for my changes (required for bug fixes, strongly encouraged for features)
  • I've tested on my platform: WSL2 on Windows 10

Documentation & Housekeeping

  • I've updated relevant documentation (README, docs/, docstrings) — or N/A
  • I've updated cli-config.yaml.example if I added/changed config keys — or N/A
  • I've updated CONTRIBUTING.md or AGENTS.md if I changed architecture or workflows — or N/A
  • I've considered cross-platform impact (Windows, macOS) per the compatibility guide — or N/A
  • I've updated tool descriptions/schemas if I changed tool behavior — or N/A

For New Skills

N/A - This is a built-in plugin, not a skill.

Screenshots / Logs

Example Output

# Request 1 - succeeds
[00:00] User: What is 1+1?
[00:15] Assistant: 2

# Request 2 - succeeds
[00:30] User: What is 2+2?
[00:40] Assistant: 4

# Request 3 - rate limit reached, waits and retries
[00:45] User: What is 3+3?
[00:45] ⏳ nvidia rate limit active — resets in 15s. Waiting...
[01:00] Assistant: 6

Rate Limit Status

/ratelimit status
Rate limiter: ✓ ENABLED
Default cooldown: 5m
✓ No active rate limit. Requests will proceed normally.

Rate Limit Active

/ratelimit status
Rate limiter: ✓ ENABLED
Default cooldown: 5m
⚠ Rate limit active. Resets in 2m 30s.

- Add /ratelimit slash commands (status, clear, enable, disable, set)
- Provides runtime controls for the cross-session rate limit guard
- Core enforcement is always active via pre_llm_call and post_llm_call hooks
- This plugin adds /ratelimit runtime controls without editing config.yaml
- Supports enable/disable functionality with config persistence
- Configurable default cooldown via /ratelimit set command
- Config file stored at $HERMES_HOME/rate_limits/config.json
- State file stored at $HERMES_HOME/rate_limits/nous.json
- Comprehensive validation for all input types
- Atomic writes for safe concurrent access
@alt-glitch alt-glitch added type/feature New feature or request P3 Low — cosmetic, nice to have comp/plugins Plugin system and bundled plugins labels Apr 22, 2026
@alt-glitch

Copy link
Copy Markdown
Collaborator

Related to previously closed attempts: #13936, #13916, #13307. Also related to #13579 (429 fallback in aux client) and #10568 (merged rate limit guard).

1 similar comment
@alt-glitch

Copy link
Copy Markdown
Collaborator

Related to previously closed attempts: #13936, #13916, #13307. Also related to #13579 (429 fallback in aux client) and #10568 (merged rate limit guard).

- Updated pre_llm_call hook to inject context when rate-limited
- Fixed hook signatures to match Hermes plugin system
- Updated documentation to reflect correct behavior
- pre_llm_call cannot block LLM calls, only inject context
- Users will see warning message when rate-limited
- Add check_rate_limit_before_call() function that tracks request counts per minute
- Modify run_agent.py to call rate limit check BEFORE making API calls
- Plugin now actively blocks requests when approaching rate limits
- Update README with new proactive rate limiting behavior
- Update plugin.yaml to v2.0.0 with new description

This fixes the issue where the plugin could only inject context but not
actually prevent 429 errors. Now it proactively tracks request counts and
waits when approaching the configured rate limit.
@LVT382009 LVT382009 changed the title feat: Add side client rate limit plugin with runtime controls feat: Add proactive rate limiting to prevent 429 errors Apr 22, 2026
@LVT382009 LVT382009 changed the title feat: Add proactive rate limiting to prevent 429 errors feat(plugin): Add proactive rate limiting to prevent 429 errors Apr 22, 2026
- Add agent/rate_limiter.py with FixedWindowRateLimiter and ProviderRateLimiterRegistry
- Update plugins/rate-limiter/commands.py with RPM-based controls (status, enable, disable, set)
- Update plugins/rate-limiter/__init__.py for plugin registration
- Update plugins/rate-limiter/plugin.yaml with new metadata

This provides client-side rate limiting that prevents 429 errors by pacing
API calls to stay within configurable requests-per-minute limits.
@LVT382009

Copy link
Copy Markdown
Contributor Author

ts PR so complex so tek dont want ts

@teknium1 teknium1 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for pursuing proactive throttling. Current main already prevents retry amplification for confirmed Nous account limits (agent/conversation_loop.py:1100-1149, 3242-3308) and covers the auxiliary Nous path (agent/auxiliary_client.py:1966-1981), but it does not provide this PR's generic pacing feature.

Problems

  • run_agent.py:9086 imports plugins.rate_limiter, but this PR adds plugins/rate-limiter/; the caught ImportError at lines 9109-9113 silently disables the proposed guard.
  • plugins/rate-limiter/rate_limiter.py:303-341 has an unlocked cross-process read/check/write sequence. Atomic rename does not make reservation atomic, so concurrent sessions can oversubscribe the configured RPM.
  • plugins/rate-limiter/__init__.py:14-20 registers only the slash command, not the claimed LLM hooks. The test also calls a nonexistent commands.ratelimit_status() at tests/plugins/test_rate_limiter_plugin.py:195.
  • The changed retry loop moved to agent/conversation_loop.py in 053025238434cfbf121873977b39888d7f27d1c1.

Suggested changes

  • Rework the active conversation_loop seam, make slot reservation inter-process atomic, and add a concurrent-process regression test.
  • Use the current plugin hook contracts or remove the unused hook path; keep behavioral configuration in config.yaml.

Automated hermes-sweeper review.

Comment thread run_agent.py
# the API call. This prevents retry amplification and 429 errors.
# Works for all providers, not just Nous.
try:
from plugins.rate_limiter.rate_limiter import (

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This import cannot resolve the added plugins/rate-limiter/ directory as plugins.rate_limiter; the following except ImportError silently disables the general guard. Rework this through the plugin loader or a real core-owned module.

# Get or create request tracker for this provider
tracker_key = f"{provider_key}:{model}" if model else provider_key

# Load existing tracker state

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Atomic rename protects against a torn file but not this read/check/write reservation. Two processes can both read an under-limit tracker and both append, exceeding the claimed cross-session RPM limit; reserve the slot under an inter-process lock or transactional store.


def register(ctx) -> None:
"""Register the rate limiter plugin with the plugin system."""
ctx.register_command(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Only the slash command is registered here. check_rate_limit and record_rate_limit_hook are never registered with ctx.register_hook, so the documented pre_llm_call and post_llm_call behavior cannot run.

commands = _load_commands()

# Initially no rate limit
result = commands.ratelimit_status()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The changed commands module exposes handle() and _status(), not ratelimit_status(), so this test calls a nonexistent API. Exercise the registered slash-command handler instead.

@teknium1 teknium1 added sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades sweeper:blast-broad Sweeper blast radius: broad — a core path most sessions hit labels Jul 12, 2026

@GottZ GottZ left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was generated by AI during triage.

Summary

Two PRs address generic proactive request pacing: #13307 adds a compact in-process provider-aware limiter around API calls, while #14029 expands the idea into two overlapping limiter implementations plus cross-session state, plugin controls, tests, and documentation, but does not connect that design correctly to the active request path.

Related pull requests

  • #13307 [closed] duplicate — (+374/-0) — keep closed: This remains relevant as the smaller prior implementation of the same provider-specific RPM pacing concept, but its integration targets the former run_agent.py request path and it has been superseded in scope by #14029 and subsequent main-branch retry-guard changes.
  • #14029 related — (+1370/-4) — close rather than merge: Despite the keep_open review on #14029, the diff imports plugins.rate_limiter from a hyphenated plugins/rate-limiter directory, leaves the claimed hooks unregistered, tests nonexistent command functions, modifies a retry loop that has moved to agent/conversation_loop.py, and performs cross-process request reservation through an unlocked read/check/write sequence; these are architectural integration failures, not a small salvage patch.

Duplicates

#13307 and #14029 substantially duplicate the same generic provider-aware proactive RPM limiter; #14029 additionally attempts cross-session 429 state and plugin controls, but those additions are not correctly wired or concurrency-safe.

Suggested consolidation

Merge neither — keep #13307 closed and close #14029 as the larger, stale duplicate. If generic pacing is still desired beyond the Nous-specific guards already on main, extract the focused limiter concept into a fresh PR against the active agent/conversation_loop.py path with an importable module layout, atomic cross-process reservation or explicitly session-local semantics, registered hooks, and tests that exercise the real command and request paths.

Complex graph

flowchart LR
    classDef open fill:#dbeafe,stroke:#1d4ed8,color:#1e3a8a
    classDef merged fill:#dcfce7,stroke:#15803d,color:#14532d
    classDef closed fill:#e5e7eb,stroke:#6b7280,color:#1f2937
    classDef unverified fill:#f3f4f6,stroke:#9ca3af,color:#374151
    classDef best stroke-width:3px,stroke:#b45309
    classDef target stroke-width:3px,stroke:#4338ca
    subgraph Dup13307 ["PRs duplicating each other"]
        P13307["PR #13307 (closed)"]
        P14029["PR #14029 (open)"]
    end
    class P13307 closed
    class P14029 open
    class P14029 target
    click P13307 "https://github.com/NousResearch/hermes-agent/pull/13307"
    click P14029 "https://github.com/NousResearch/hermes-agent/pull/14029"
Loading

Graph: solid arrow = fixes / best fix, dashed arrow = partial or unverified (see edge label); boxed group = PRs duplicating each other; amber border = best fix; indigo border = target; gray node = closed or no verify verdict yet (state tag in the node label).

Cross-PR triage: Reviewed 2 pull requests and 0 issues in this complex. Each diff was read against this issue; Assessment working set: 65 kB of PR diffs, 16 kB of issue/PR text, 2 kB of discussion (4 comments), 1 verify verdict. verdicts reflect diff content, not PR titles. Part of an automated triage batch.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

comp/plugins Plugin system and bundled plugins P3 Low — cosmetic, nice to have sweeper:blast-broad Sweeper blast radius: broad — a core path most sessions hit sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades type/feature New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants