Files
pumpfun-bonkfun-bot_github/src/core/rpc_rate_limiter.py
T
Alex Kuligowski 40ad32b8d0 feat(core): add RPC rate limiting and retry handling (#154)
* feat(core): add RPC rate limiting, retry logic, and 429 handling

Addresses #44 — users on free-tier RPC endpoints hit HTTP 429 errors
during buy transactions due to no rate limiting or retry handling.

- Add TokenBucketRateLimiter (new file: src/core/rpc_rate_limiter.py)
- Gate all RPC methods through rate limiter (both post_rpc and solana-py calls)
- Rewrite post_rpc() with retry loop, exponential backoff, jitter, and
  specific 429 detection with Retry-After header support
- Replace per-call aiohttp session with shared persistent session
- Wire node.max_rps from YAML bot config through to SolanaClient
- Fix cleanup manager and learning example to use SolanaClient abstraction
  instead of bypassing it via get_client()

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(core): address CodeRabbitAI review feedback on rate limiting PR

Validate max_rps > 0 in TokenBucketRateLimiter to prevent ZeroDivisionError
and infinite loops with fractional values. Add asyncio.Lock to _get_session
to fix race condition, handle non-numeric Retry-After headers gracefully,
replace dead json.JSONDecodeError with aiohttp.ContentTypeError, and combine
burn+close into a single transaction in cleanup example.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* docs: document RPC rate limiting feature in README

- Add section on built-in RPC rate limiting with token bucket algorithm
- Document configurable max RPS and automatic retry logic
- Update roadmap to mark "Configurable RPS" as completed
- Clarify benefits of rate limiting for provider compliance

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

* fix: use math.ceil for burst_size to handle fractional max_rps

- Replace int(max_rps) with math.ceil(max_rps) in burst_size calculation
- Prevents infinite loop when max_rps < 1.0 (e.g., 0.5 RPS would result in burst_size=0)
- Ensures burst_size is always at least 1 for valid fractional rates
- Addresses CodeRabbit feedback on rpc_rate_limiter.py:27

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

* fix(core): validate burst_size and fix table pipe consistency

Address remaining CodeRabbitAI review feedback: add burst_size
validation guard, fix TRY003 lint (use msg variable for ValueError),
break long line under 88 chars, and fix MD055 table pipe style in README.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(core): separate 429 retry budget from error retries in post_rpc

429 responses no longer count against max_retries — they use a dedicated
max_429_retries counter (default 10) so free-tier users hitting rate
limits won't exhaust retries prematurely. Also refresh the aiohttp
session inside the retry loop to avoid stale references after network
failures, and fix cleanup log message accuracy.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Anton Sauchyk <antonsauchyk@gmail.com>
2026-02-17 10:02:11 +01:00

60 lines
1.9 KiB
Python

"""Token bucket rate limiter for Solana RPC requests."""
import asyncio
import math
import time
from utils.logger import get_logger
logger = get_logger(__name__)
class TokenBucketRateLimiter:
"""Token bucket rate limiter for controlling RPC request rate.
Implements a token bucket algorithm that replenishes tokens at a
fixed rate. Each RPC call consumes one token. When the bucket is
empty, callers wait until a token becomes available.
Args:
max_rps: Maximum requests per second (bucket refill rate). Must be positive.
burst_size: Maximum burst size (bucket capacity). Defaults to max_rps.
"""
def __init__(self, max_rps: float, burst_size: int | None = None) -> None:
if max_rps <= 0:
msg = f"max_rps must be positive, got {max_rps}"
raise ValueError(msg)
self._max_rps = max_rps
self._burst_size = (
burst_size if burst_size is not None else max(1, math.ceil(max_rps))
)
if self._burst_size <= 0:
msg = f"burst_size must be positive, got {burst_size}"
raise ValueError(msg)
self._tokens = float(self._burst_size)
self._last_refill = time.monotonic()
self._lock = asyncio.Lock()
async def acquire(self) -> None:
"""Acquire a token, waiting if the bucket is empty."""
while True:
async with self._lock:
self._refill()
if self._tokens >= 1.0:
self._tokens -= 1.0
return
wait_time = (1.0 - self._tokens) / self._max_rps
await asyncio.sleep(wait_time)
def _refill(self) -> None:
"""Refill tokens based on elapsed time since last refill."""
now = time.monotonic()
elapsed = now - self._last_refill
self._tokens = min(
self._burst_size,
self._tokens + elapsed * self._max_rps,
)
self._last_refill = now