From e10efaa1293558950424032f4dec3e4149b94bd2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ebbe=20Ba=C3=9F?= Date: Wed, 9 Sep 2026 13:54:06 +0200 Subject: [PATCH] added rate-limiting for specific requests --- api/README.md | 14 +++++++++++++- api/main.py | 18 +++++++++++++++++- api/rate_limit.py | 36 ++++++++++++++++++++++++++++++++++++ 3 files changed, 66 insertions(+), 2 deletions(-) create mode 100644 api/rate_limit.py diff --git a/api/README.md b/api/README.md index 0bf8991..a4a2698 100644 --- a/api/README.md +++ b/api/README.md @@ -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:///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 diff --git a/api/main.py b/api/main.py index 6e959bd..9bc6b84 100644 --- a/api/main.py +++ b/api/main.py @@ -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: diff --git a/api/rate_limit.py b/api/rate_limit.py new file mode 100644 index 0000000..c367a3e --- /dev/null +++ b/api/rate_limit.py @@ -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