Repository navigation
feat(plugin): Add proactive rate limiting to prevent 429 errors #14029
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
LVT382009
wants to merge
4
commits into
NousResearch:main
Choose a base branch
from
LVT382009:pr-13936
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
4 commits
Select commit
Hold shift + click to select a range
707b662
feat: Add rate limiter plugin with slash commands
LVT382009 158941b
fix: rate limiter now injects context instead of blocking
LVT382009 fc97ad0
feat: add proactive rate limiting to prevent 429 errors
LVT382009 fcc6c3a
feat: add built-in rate limiter with RPM controls
LVT382009 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,276 @@ | ||
| """ | ||
| Client-side fixed-window rate limiter for Hermes Agent. | ||
|
|
||
| Paces outgoing API calls to stay within a configurable requests-per-minute | ||
| ceiling, preventing 429 errors before they occur. | ||
|
|
||
| Core classes: | ||
| - FixedWindowRateLimiter: Fixed window with full 60s wait after limit reached | ||
| - ProviderRateLimiterRegistry: Case-insensitive, prefix-aware provider routing | ||
| - make_registry(): Factory reads config + env var | ||
|
|
||
| Lock safety: time.sleep() always runs outside the lock. | ||
| Default: rpm=0 (disabled). Zero behaviour change for existing users. | ||
| No external dependencies — pure stdlib. | ||
| """ | ||
|
|
||
| import collections | ||
| import os | ||
| import threading | ||
| import time | ||
| from typing import Dict | ||
|
|
||
|
|
||
| def _validate_rpm(rpm: any) -> int: | ||
| """ | ||
| Validate and normalize RPM value. | ||
|
|
||
| Handles invalid values gracefully: | ||
| - Non-integer values → 0 (disabled) | ||
| - Negative values → 0 (disabled) | ||
| - Values > 3600 → 3600 (max allowed) | ||
| - None/missing → 0 (disabled) | ||
|
|
||
| Args: | ||
| rpm: RPM value from config or env var | ||
|
|
||
| Returns: | ||
| Validated RPM value (0-3600) | ||
| """ | ||
| try: | ||
| rpm_int = int(rpm) | ||
| except (TypeError, ValueError): | ||
| return 0 # Invalid value → disabled | ||
|
|
||
| if rpm_int < 0: | ||
| return 0 # Negative → disabled | ||
| if rpm_int > 3600: | ||
| return 3600 # Cap at max | ||
|
|
||
| return rpm_int | ||
|
|
||
|
|
||
| class _NoOpLimiter: | ||
| """Zero-cost NOOP limiter for disabled state.""" | ||
| rpm = 0 | ||
|
|
||
| def acquire(self) -> float: | ||
| """Returns 0.0 instantly — no limiting.""" | ||
| return 0.0 | ||
|
|
||
|
|
||
| _NOOP = _NoOpLimiter() | ||
|
|
||
|
|
||
| class FixedWindowRateLimiter: | ||
| """ | ||
| Fixed window rate limiter with full 60s wait after limit is reached. | ||
|
|
||
| Once the RPM limit is reached, the next request must wait a full 60 seconds | ||
| from when the window started, not just until the oldest request expires. | ||
|
|
||
| Uses time.monotonic() for drift-free timing. | ||
| Lock held only for window state operations. | ||
| time.sleep() ALWAYS outside the lock. | ||
| """ | ||
|
|
||
| def __init__(self, rpm: int): | ||
| self._rpm = rpm | ||
| self._window_start = 0.0 # When the current window started | ||
| self._request_count = 0 # Requests made in current window | ||
| self._lock = threading.Lock() | ||
|
|
||
| @property | ||
| def rpm(self) -> int: | ||
| """Current RPM setting.""" | ||
| return self._rpm | ||
|
|
||
| def acquire(self) -> float: | ||
| """ | ||
| Block until a request slot is available. | ||
|
|
||
| Returns: | ||
| Seconds waited (0.0 if no wait needed). | ||
|
|
||
| Fail-safe: Returns 0.0 on any exception (degrade to no limiting). | ||
| """ | ||
| if self._rpm == 0: | ||
| return 0.0 | ||
|
|
||
| wait_start = time.monotonic() | ||
|
|
||
| while True: | ||
| try: | ||
| with self._lock: | ||
| now = time.monotonic() | ||
|
|
||
| # Check if we need to start a new window | ||
| if self._window_start == 0.0: | ||
| # First request - start the window | ||
| self._window_start = now | ||
| self._request_count = 1 | ||
| return time.monotonic() - wait_start | ||
|
|
||
| # Check if current window has expired (60s from start) | ||
| window_age = now - self._window_start | ||
| if window_age >= 60.0: | ||
| # Start a new window | ||
| self._window_start = now | ||
| self._request_count = 1 | ||
| return time.monotonic() - wait_start | ||
|
|
||
| # Check if we have room in the current window | ||
| if self._request_count < self._rpm: | ||
| # Allow the request | ||
| self._request_count += 1 | ||
| return time.monotonic() - wait_start | ||
|
|
||
| # Limit reached - calculate wait time | ||
| # Wait until 60s from window start (full window) | ||
| sleep_for = 60.0 - window_age | ||
|
|
||
| except Exception: | ||
| # Fail-open: no limiting on errors (no state mutation) | ||
| return 0.0 | ||
|
|
||
| # Sleep is ALWAYS outside the lock | ||
| time.sleep(max(sleep_for, 0.01)) | ||
|
|
||
|
|
||
| class ProviderRateLimiterRegistry: | ||
| """ | ||
| Registry of per-provider rate limiters with thread-safe runtime updates. | ||
|
|
||
| Lock separation: | ||
| - FixedWindowRateLimiter._lock: protects window operations within individual limiters | ||
| - ProviderRateLimiterRegistry._registry_lock: protects mutation + snapshot only | ||
|
|
||
| Immutability rules: | ||
| - Provider-specific limiters are immutable (created once, never replaced) | ||
| - Only default limiter is replaced via set_default_rpm() | ||
| """ | ||
|
|
||
| def __init__(self, default_rpm: int, providers: Dict[str, int]): | ||
| self._default = _NOOP if default_rpm == 0 else FixedWindowRateLimiter(default_rpm) | ||
| self._providers = { | ||
| k.lower(): FixedWindowRateLimiter(_validate_rpm(v)) | ||
| for k, v in providers.items() | ||
| } | ||
| self._registry_lock = threading.Lock() | ||
|
|
||
| def resolve(self, provider: str | None) -> FixedWindowRateLimiter: | ||
| """ | ||
| Resolve the appropriate rate limiter for a provider. | ||
|
|
||
| Uses zero-copy read pattern (providers are immutable). | ||
|
|
||
| Args: | ||
| provider: Provider name (case-insensitive, prefix-aware) | ||
|
|
||
| Returns: | ||
| Appropriate rate limiter (default if no match) | ||
| """ | ||
| with self._registry_lock: | ||
| default = self._default | ||
| providers = self._providers # no copy - providers are immutable | ||
|
|
||
| # Do matching OUTSIDE lock to minimize contention | ||
| if not provider: | ||
| return default | ||
|
|
||
| key = provider.lower() | ||
|
|
||
| # Exact match | ||
| if key in providers: | ||
| return providers[key] | ||
|
|
||
| # Prefix match (e.g., "nvidia-nim" → "nvidia") | ||
| for name, limiter in providers.items(): | ||
| if key.startswith(name): | ||
| return limiter | ||
|
|
||
| return default | ||
|
|
||
| def set_default_rpm(self, rpm: int) -> None: | ||
| """ | ||
| Thread-safe runtime update of default RPM. | ||
|
|
||
| Atomic replacement of default limiter instance. | ||
|
|
||
| Args: | ||
| rpm: New RPM setting (0 = disabled) | ||
| """ | ||
| with self._registry_lock: | ||
| self._default = _NOOP if rpm == 0 else FixedWindowRateLimiter(rpm) | ||
|
|
||
| def reload_from_config(self, config: Dict) -> None: | ||
| """ | ||
| Reload the entire registry from config. | ||
|
|
||
| This is useful when the config is changed externally and you want | ||
| to reload the rate limiter without restarting the agent. | ||
|
|
||
| Args: | ||
| config: Configuration dict with rate_limit section | ||
| """ | ||
| rl_cfg = config.get("rate_limit", {}) | ||
| env_rpm = os.environ.get("HERMES_REQUESTS_PER_MINUTE") | ||
| config_rpm = rl_cfg.get("requests_per_minute") | ||
|
|
||
| # Use env var if set, otherwise use config, otherwise default to 0 | ||
| if env_rpm is not None: | ||
| default_rpm = _validate_rpm(env_rpm) | ||
| elif config_rpm is not None: | ||
| default_rpm = _validate_rpm(config_rpm) | ||
| else: | ||
| default_rpm = 0 | ||
|
|
||
| providers = rl_cfg.get("providers", {}) | ||
|
|
||
| with self._registry_lock: | ||
| self._default = _NOOP if default_rpm == 0 else FixedWindowRateLimiter(default_rpm) | ||
| self._providers = { | ||
| k.lower(): FixedWindowRateLimiter(_validate_rpm(v)) | ||
| for k, v in providers.items() | ||
| } | ||
|
|
||
|
|
||
| def make_registry(config: Dict) -> ProviderRateLimiterRegistry: | ||
| """ | ||
| Factory function to create a rate limiter registry from config. | ||
|
|
||
| Reads from config dict and HERMES_REQUESTS_PER_MINUTE env var. | ||
|
|
||
| Args: | ||
| config: Configuration dict with rate_limit section | ||
|
|
||
| Returns: | ||
| Configured ProviderRateLimiterRegistry | ||
| """ | ||
| rl_cfg = config.get("rate_limit", {}) | ||
| env_rpm = os.environ.get("HERMES_REQUESTS_PER_MINUTE") | ||
| config_rpm = rl_cfg.get("requests_per_minute") | ||
|
|
||
| # Use env var if set, otherwise use config, otherwise default to 0 | ||
| if env_rpm is not None: | ||
| default_rpm = _validate_rpm(env_rpm) | ||
| elif config_rpm is not None: | ||
| default_rpm = _validate_rpm(config_rpm) | ||
| else: | ||
| default_rpm = 0 | ||
|
|
||
| providers = rl_cfg.get("providers", {}) | ||
| return ProviderRateLimiterRegistry(default_rpm, providers) | ||
|
|
||
|
|
||
| def make_rate_limiter(rpm: int) -> FixedWindowRateLimiter: | ||
| """ | ||
| Simple factory to create a rate limiter. | ||
|
|
||
| Args: | ||
| rpm: Requests per minute (0 = disabled) | ||
|
|
||
| Returns: | ||
| FixedWindowRateLimiter instance | ||
| """ | ||
| return FixedWindowRateLimiter(rpm) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,96 @@ | ||
| # rate-limiter | ||
|
|
||
| Cross-session rate limit guard for side clients (Nous Portal, NVIDIA, etc.) that prevents | ||
| retry amplification and 429 errors by proactively blocking requests before hitting the API. | ||
|
|
||
| ## Problem | ||
|
|
||
| Each 429 (rate limit) error from a provider triggers up to 9 API calls per conversation turn: | ||
| - 3 SDK retries × 3 Hermes retries = 9 calls | ||
| - Every call counts against RPH (requests per hour) | ||
| - This amplification can quickly exhaust rate limits | ||
|
|
||
| ## Solution | ||
|
|
||
| This plugin has TWO layers of protection: | ||
|
|
||
| 1. **Proactive rate limiting** (NEW): Tracks request counts per minute and blocks requests BEFORE hitting the API when approaching the limit | ||
| 2. **Reactive rate limiting**: Records rate limit state on the first 429 and checks it before subsequent attempts across all sessions | ||
|
|
||
| ## How it works | ||
|
|
||
| ### Proactive Rate Limiting (NEW) | ||
|
|
||
| The plugin tracks request counts per minute for each provider and blocks requests when approaching the configured limit: | ||
|
|
||
| | Provider | Config Key | Example | | ||
| |----------|-----------|---------| | ||
| | NVIDIA | `nvidia` | `rate_limit.nvidia.requests_per_minute: 40` | | ||
| | OpenRouter | `openrouter` | `rate_limit.openrouter.requests_per_minute: 60` | | ||
| | Nous | `nous` | `rate_limit.nous.requests_per_minute: 30` | | ||
| | Default | `default` | `rate_limit.default.requests_per_minute: 40` | | ||
|
|
||
| When the limit is reached: | ||
| - The plugin waits up to 60 seconds for the rate limit to reset | ||
| - A message is displayed: "⏳ nvidia rate limit active — resets in 15s. Waiting..." | ||
| - After waiting, the request is retried | ||
|
|
||
| ### Reactive Rate Limiting (Legacy) | ||
|
|
||
| | Hook | Behaviour | | ||
| |---|---| | ||
| | `pre_llm_call` | Check if provider is currently rate-limited. If so, inject context to inform the user about the rate limit. | | ||
| | `post_llm_call` | If a 429 error is received, parse reset time from headers/error context and record to shared state file. | | ||
|
|
||
| **NOTE**: The reactive layer is kept for backward compatibility with the existing `nous_rate_guard.py` system. | ||
|
|
||
| ## Configuration | ||
|
|
||
| Add rate limits to your `~/.hermes/config.yaml`: | ||
|
|
||
| ```yaml | ||
| rate_limit: | ||
| default: | ||
| requests_per_minute: 40 # Default limit for all providers | ||
| nvidia: | ||
| requests_per_minute: 40 # NVIDIA-specific limit | ||
| openrouter: | ||
| requests_per_minute: 60 # OpenRouter-specific limit | ||
| nous: | ||
| requests_per_minute: 30 # Nous-specific limit | ||
| ``` | ||
|
|
||
| ## Slash commands | ||
|
|
||
| ``` | ||
| /ratelimit status # Show current rate limit status | ||
| /ratelimit clear # Clear rate limit state manually | ||
| /ratelimit enable # Enable the rate limiter | ||
| /ratelimit disable # Disable the rate limiter | ||
| /ratelimit set <s> # Set default cooldown time (seconds) | ||
| ``` | ||
|
|
||
| ## Safety | ||
|
|
||
| - Atomic writes: temp file + rename for safe concurrent access | ||
| - Expired entries are automatically cleaned up on read | ||
| - State file is scoped to `$HERMES_HOME/rate_limits/` | ||
| - No external dependencies beyond standard library | ||
| - Proactive blocking prevents 429 errors before they happen | ||
|
|
||
| ## Testing | ||
|
|
||
| Run the test suite: | ||
|
|
||
| ```bash | ||
| pytest tests/plugins/test_rate_limiter_plugin.py -v | ||
| ``` | ||
|
|
||
| Tests cover: | ||
| - Header parsing (priority order, invalid values) | ||
| - Rate limit recording (headers, error context, default cooldown) | ||
| - Rate limit checking (remaining time, expiration) | ||
| - Plugin hooks (pre_llm_call, post_llm_call) | ||
| - Slash commands (status, clear, enable, disable, set) | ||
| - Bundled plugin discovery | ||
| - Proactive rate limiting (request tracking, limit enforcement) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,20 @@ | ||
| """Rate limiter plugin for Hermes Agent. | ||
|
|
||
| Provides runtime controls for the built-in rate limiter via /ratelimit slash command. | ||
|
|
||
| Core enforcement is always active via run_agent.py — this plugin adds runtime controls | ||
| to enable/disable rate limiting and adjust RPM without editing config.yaml. | ||
| """ | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| from . import commands | ||
|
|
||
|
|
||
| def register(ctx) -> None: | ||
| """Register the rate limiter plugin with the plugin system.""" | ||
| ctx.register_command( | ||
| "ratelimit", | ||
| handler=commands.handle, | ||
| description="Runtime controls for the built-in rate limiter.", | ||
| ) | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
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_limitandrecord_rate_limit_hookare never registered withctx.register_hook, so the documentedpre_llm_callandpost_llm_callbehavior cannot run.