added rate-limiting for specific requests

This commit is contained in:
Ebbe Baß
2026-09-09 13:54:06 +02:00
parent 9d52d869fb
commit e10efaa129
3 changed files with 66 additions and 2 deletions
+13 -1
View File
@@ -55,6 +55,18 @@ an env var nor a matching crontab line is found, you just get the
last-scrape info with `next_scrape_at` / `seconds_until_next_scrape` as
`null`.
### `/reload` rate limiting
`POST /reload` is limited to one call per `RELOAD_MIN_INTERVAL_SECONDS`
(default 10) - a call within that window returns `429` with a `Retry-After`
header instead of re-reading the file(s). This is a single shared cooldown,
not per-caller, so it also protects the server if several callers hit it at
once.
```bash
RELOAD_MIN_INTERVAL_SECONDS=30 uvicorn main:app
```
## Endpoints
| Method | Path | Description |
@@ -65,7 +77,7 @@ last-scrape info with `next_scrape_at` / `seconds_until_next_scrape` as
| GET | `/events/{index}` | Single event by its position in the merged list (0-based) |
| GET | `/artists` | Deduplicated, sorted list of all artist names |
| GET | `/calendar.ics` | iCalendar/webcal feed of all events — subscribe with `webcal://<host>/calendar.ics` |
| POST | `/reload` | Re-read the data file(s) from disk (after a fresh scrape) |
| POST | `/reload` | Re-read the data file(s) from disk (after a fresh scrape) - rate-limited, see below |
### `/events` query parameters
+17 -1
View File
@@ -17,6 +17,7 @@ from fastapi.responses import PlainTextResponse
from calendar_feed import build_ics
from datasource import EventStore, iter_artist_names, parse_event_date
from models import Event, EventList
from rate_limit import Cooldown
from scrape_schedule import discover_cron_expr, get_schedule_info
app = FastAPI(
@@ -27,6 +28,9 @@ app = FastAPI(
store = EventStore()
_RELOAD_MIN_INTERVAL_SECONDS = float(os.environ.get("RELOAD_MIN_INTERVAL_SECONDS", "10"))
_reload_cooldown = Cooldown(_RELOAD_MIN_INTERVAL_SECONDS)
def _matches(
event: Event,
@@ -82,7 +86,19 @@ def health():
@app.post("/reload")
def reload():
"""Re-read the data file(s) from disk (e.g. after a fresh scrape)."""
"""Re-read the data file(s) from disk (e.g. after a fresh scrape).
Rate-limited to one call per RELOAD_MIN_INTERVAL_SECONDS (default 10s)
to stop it being hammered - a fresh scrape doesn't land any more often
than that anyway.
"""
remaining = _reload_cooldown.try_acquire()
if remaining is not None:
raise HTTPException(
status_code=429,
detail=f"Reload rate-limited; try again in {remaining:.1f}s",
headers={"Retry-After": str(int(remaining) + 1)},
)
try:
count = store.reload()
except FileNotFoundError as exc:
+36
View File
@@ -0,0 +1,36 @@
"""A minimal cooldown gate to stop a single endpoint from being spammed.
Not a general-purpose rate limiter - no per-client/IP tracking, no token
bucket. Just one shared "don't run again until N seconds have passed since
the last call" gate, which is all a low-traffic single-instance endpoint
like /reload needs.
"""
import threading
import time
from typing import Optional
class Cooldown:
"""Gate that allows one call per ``interval_seconds``, shared by all callers."""
def __init__(self, interval_seconds: float):
self._interval = interval_seconds
self._lock = threading.Lock()
self._last_call: Optional[float] = None
def try_acquire(self) -> Optional[float]:
"""Attempt to pass the gate.
Returns None if allowed (and records this call as the new last
call). Returns the number of seconds still left to wait if the
gate is still cooling down.
"""
now = time.monotonic()
with self._lock:
if self._last_call is not None:
remaining = self._interval - (now - self._last_call)
if remaining > 0:
return remaining
self._last_call = now
return None