added rate-limiting for specific requests
This commit is contained in:
+13
-1
@@ -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
|
last-scrape info with `next_scrape_at` / `seconds_until_next_scrape` as
|
||||||
`null`.
|
`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
|
## Endpoints
|
||||||
|
|
||||||
| Method | Path | Description |
|
| 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 | `/events/{index}` | Single event by its position in the merged list (0-based) |
|
||||||
| GET | `/artists` | Deduplicated, sorted list of all artist names |
|
| GET | `/artists` | Deduplicated, sorted list of all artist names |
|
||||||
| GET | `/calendar.ics` | iCalendar/webcal feed of all events — subscribe with `webcal://<host>/calendar.ics` |
|
| 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
|
### `/events` query parameters
|
||||||
|
|
||||||
|
|||||||
+17
-1
@@ -17,6 +17,7 @@ from fastapi.responses import PlainTextResponse
|
|||||||
from calendar_feed import build_ics
|
from calendar_feed import build_ics
|
||||||
from datasource import EventStore, iter_artist_names, parse_event_date
|
from datasource import EventStore, iter_artist_names, parse_event_date
|
||||||
from models import Event, EventList
|
from models import Event, EventList
|
||||||
|
from rate_limit import Cooldown
|
||||||
from scrape_schedule import discover_cron_expr, get_schedule_info
|
from scrape_schedule import discover_cron_expr, get_schedule_info
|
||||||
|
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
@@ -27,6 +28,9 @@ app = FastAPI(
|
|||||||
|
|
||||||
store = EventStore()
|
store = EventStore()
|
||||||
|
|
||||||
|
_RELOAD_MIN_INTERVAL_SECONDS = float(os.environ.get("RELOAD_MIN_INTERVAL_SECONDS", "10"))
|
||||||
|
_reload_cooldown = Cooldown(_RELOAD_MIN_INTERVAL_SECONDS)
|
||||||
|
|
||||||
|
|
||||||
def _matches(
|
def _matches(
|
||||||
event: Event,
|
event: Event,
|
||||||
@@ -82,7 +86,19 @@ def health():
|
|||||||
|
|
||||||
@app.post("/reload")
|
@app.post("/reload")
|
||||||
def 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:
|
try:
|
||||||
count = store.reload()
|
count = store.reload()
|
||||||
except FileNotFoundError as exc:
|
except FileNotFoundError as exc:
|
||||||
|
|||||||
@@ -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
|
||||||
Reference in New Issue
Block a user