93 lines
3.0 KiB
Python
93 lines
3.0 KiB
Python
"""
|
|
mangabaka_rate_limit.py
|
|
=======================
|
|
|
|
Process-wide rate limiter for the MangaBaka API.
|
|
|
|
Apply via:
|
|
|
|
from MangaBakaRateLimit import apply_to_session
|
|
apply_to_session(session)
|
|
|
|
This mounts a custom ``requests.adapters.HTTPAdapter`` on the given
|
|
``requests.Session`` for the ``api.mangabaka.dev`` host. Every request
|
|
going through that adapter is:
|
|
|
|
* throttled so that no two requests are dispatched within
|
|
``_MIN_INTERVAL`` seconds of one another, and
|
|
* retried on HTTP 429, honouring the ``Retry-After`` header when
|
|
present, otherwise exponential backoff capped at ``_MAX_BACKOFF``.
|
|
|
|
Throttle state is module-global, so even if several sessions exist in
|
|
the same process they share one budget — important because they all hit
|
|
the same upstream IP-based limit.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import threading
|
|
import time
|
|
|
|
from requests.adapters import HTTPAdapter
|
|
|
|
|
|
# Tune these if MangaBaka tightens or loosens limits.
|
|
_MIN_INTERVAL = 1.1 # seconds between consecutive requests
|
|
_MAX_RETRIES = 6 # retries on 429 before giving up
|
|
_MAX_BACKOFF = 60.0 # cap on per-attempt backoff sleep
|
|
|
|
|
|
# --- shared throttle state --------------------------------------------------
|
|
_state_lock = threading.Lock()
|
|
_last_request_time = 0.0
|
|
|
|
|
|
def _wait_for_slot() -> None:
|
|
"""Block until the next request slot is available, then reserve it."""
|
|
global _last_request_time
|
|
while True:
|
|
with _state_lock:
|
|
now = time.monotonic()
|
|
wait = _MIN_INTERVAL - (now - _last_request_time)
|
|
if wait <= 0:
|
|
_last_request_time = now
|
|
return
|
|
time.sleep(wait)
|
|
|
|
|
|
class _MangaBakaRateLimitAdapter(HTTPAdapter):
|
|
def send(self, request, **kwargs):
|
|
response = None
|
|
for attempt in range(_MAX_RETRIES + 1):
|
|
_wait_for_slot()
|
|
response = super().send(request, **kwargs)
|
|
if response.status_code != 429:
|
|
return response
|
|
|
|
retry_after = response.headers.get("Retry-After")
|
|
try:
|
|
wait = (float(retry_after) if retry_after
|
|
else min(_MAX_BACKOFF, 2.0 * (2 ** attempt)))
|
|
except ValueError:
|
|
wait = min(_MAX_BACKOFF, 2.0 * (2 ** attempt))
|
|
|
|
print(f"[MangaBaka] 429 — backing off {wait:.1f}s "
|
|
f"(attempt {attempt + 1}/{_MAX_RETRIES})",
|
|
flush=True)
|
|
response.close()
|
|
time.sleep(wait)
|
|
|
|
# Retries exhausted — let the caller deal with the last 429.
|
|
return response
|
|
|
|
|
|
def apply_to_session(session) -> None:
|
|
"""
|
|
Mount the rate-limit adapter on ``session`` so every MangaBaka call
|
|
is automatically throttled. Safe to call multiple times (later mounts
|
|
just replace the earlier adapter for the same prefix).
|
|
"""
|
|
adapter = _MangaBakaRateLimitAdapter()
|
|
session.mount("https://api.mangabaka.dev/", adapter)
|
|
session.mount("http://api.mangabaka.dev/", adapter)
|