fix(ui+api): das "Neuladen" war HTTP 429 - und "Neu" komprimierte immer
Ampel / ampel (push) Successful in 30s

Zwei Commander-Befunde, beide mit derselben Wurzel: Rippy hat sich selbst
ausgebremst und dann geschwiegen.

## "Wenn der Worker installiert ist, wird dieser Bereich oft neu geladen"

Gemessen statt geraten. Die Antworten von /jobs und /capabilities waren ueber
zwanzig Sekunden byteweise identisch, alle Endpunkte antworteten unter 30 ms -
es wurde also gar nichts neu geladen. Im nginx-Log standen dagegen 97 Antworten
mit HTTP 429.

Drei Fehler griffen ineinander:

1. Das Limit war zu klein fuer Rippy selbst: 100 Anfragen/min, waehrend ein
   offener Tab 111/min verursacht (Dashboard 75 + Log-Kasten 24 + Laufwerke 12)
   und der Windows-Tray weitere 12/min dazulegt.
2. Der nginx gab die Client-Adresse nicht weiter. Fuer die API kam damit ALLES
   von 172.19.0.6 - Browser, zweiter Tab und Tray teilten sich einen Eimer
   (812 von 876 Anfragen). Das erklaert die Kopplung an den Worker: tray.py
   fragt /api/jobs ueber Port 80, also durch denselben Proxy.
3. Ein abgewiesener Abruf leerte das UI. `catch(() => [])` heisst "es gibt
   keine Jobs" - richtig waere "ich weiss gerade nichts Neues". Fuer einen Takt
   stand "Keine Jobs", die Zaehler sprangen auf (0), vier Sekunden spaeter war
   alles zurueck.

Behoben: X-Real-IP im nginx, Grenze auf 600/min mit vorgerechneter Herleitung,
jeder Fehlschlag laesst den alten Stand stehen (null statt []), axios bekommt
eine Zeitgrenze, und das Dashboard trennt schnelle Daten (Jobs/Laufwerke, 4 s)
von langsamen (Hardware/Worker/Ablagen, 12 s) - 75/min werden zu 30/min.
Ein greifendes Limit steht ab jetzt im Log, gedrosselt auf eine Meldung pro
Client und Minute.

## "Hier gibt es den Button 'neu' aber WAS wird dann gemacht?"

Immer die Komprimierung - auch bei einem Job, dessen RIP abgebrochen war. Am
26.07.2026 waeren aus 5,1 GB Bruchstueck (von rund 40 GB) brav ein Film
geworden, der bei 12 % aufhoert.

Die Phase war nach `status = "failed"` nicht mehr feststellbar, also wird sie
jetzt vermerkt (rip_fertig in den Job-Metadaten: false beim Rip-Start, true bei
der Uebergabe an die Kompression). Daraus folgt die Beschriftung: "Neu
komprimieren", "Neu rippen" - oder bei Bestandsjobs ohne Vermerk ein Dialog,
der beide Wege erklaert und die Groesse der Rohdaten als Entscheidungshilfe
nennt. Geraten wird nicht. Fuer den Rip-Fall gibt es POST
/jobs/{id}/retry-rip: neuer Job mit neuer ID (sonst laege das Bruchstueck im
Roh-Verzeichnis des neuen Rips), Titel/Ablage/Sprachwahl uebernommen, mit
ehrlicher Absage wenn keine Disc im Laufwerk liegt.

10 neue Tests (289 gruen), darunter eine Kopplungspruefung: der Name der
Phasen-Marke muss in worker/tasks.py und api/phasen.py zusammenpassen - genau
diese Sorte Auseinanderdriften hat die Zombie-Erkennung ein Release lang blind
gemacht.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-07-26 16:49:10 +02:00
parent f7555a17d1
commit bfb13f44a5
11 changed files with 811 additions and 42 deletions
+114 -2
View File
@@ -18,6 +18,7 @@ import makemkv_daten
import makemkv_key
import mounts as mount_verwaltung
import notify
import phasen
import presets as preset_auswahl
import rohdaten
from celery_client import celery_client, start_rip
@@ -25,7 +26,13 @@ from detection import CDS_DISC_OK, CDS_NO_DISC, CDS_TRAY_OPEN, drive_status
from config_validation import validate_config, ConfigValidationError
from cache import get as cache_get, init_cache, set as cache_set
from ratelimit import check_rate_limit, get_rate_limit_remaining
from ratelimit import (
MAX_REQUESTS_PER_MINUTE,
check_rate_limit,
client_kennung,
darf_melden,
get_rate_limit_remaining,
)
from prescan import PreScan
# Auth (JWT/Login/API-Keys) KOMPLETT entfernt — Commander-Entscheid 24.07.2026:
@@ -225,10 +232,29 @@ async def disc_watcher():
@app.middleware("http")
async def rate_limit_middleware(request: Request, call_next):
"""Rate-Limiting Middleware."""
client_ip = request.client.host
# Nicht `request.client.host` allein: Der Browser spricht über den nginx im
# UI-Container, dessen Adresse sonst für ALLE Clients gälte (Herleitung in
# ratelimit.client_kennung).
client_ip = client_kennung(
request.client.host if request.client else "",
request.headers.get("x-real-ip") or request.headers.get("x-forwarded-for"),
)
# Rate Limit prüfen
if not check_rate_limit(client_ip):
# Ein 429 war lange unsichtbar: Das UI verbuchte ihn als „nichts da".
# Jetzt steht er im Log, damit die Bremse nicht wieder heimlich greift —
# gedrosselt, damit ein Amok-Skript nicht das Log-Fenster flutet.
try:
if darf_melden(client_ip):
db.add_log(
"warning", "api",
f"Rate-Limit erreicht für {client_ip} — Anfragen werden "
f"abgewiesen (Grenze: {MAX_REQUESTS_PER_MINUTE}/min). Läuft "
"dort ein Skript in einer Schleife?",
)
except Exception:
pass
return Response(
content=json.dumps({"error": "Rate limit exceeded"}),
status_code=429,
@@ -263,6 +289,10 @@ class Job(BaseModel):
title: Optional[str] = None
error: Optional[str] = None
can_retry: bool = False # Rohdaten vorhanden → „Neu komprimieren" sinnvoll
# Welche Wiederholung passt zu DIESEM Fehlschlag? "transcode" | "rip" |
# "unklar" | "" — Herleitung und Begründung in phasen.py. Ohne dieses Feld
# hieß der Knopf nur „Neu" und komprimierte immer, auch ein Rip-Bruchstück.
retry_art: str = ""
meta: Optional[Dict] = None # Disc-Metadaten (Poster/Jahr/Plot) — fürs Thumbnail in der Jobliste + aktivem Rip-Header
# Restzeit-Schätzung (siehe eta.py). -1/"" heißt „noch keine Aussage" —
# bewusst ehrlich statt einer erfundenen Minutenzahl.
@@ -577,6 +607,7 @@ async def get_jobs():
for z in db.list_jobs():
modell = _job_row_to_model(z)
modell.can_retry = _kann_neu_komprimieren(z, work_dir)
modell.retry_art = phasen.retry_art(z)
schaetzung = eta.aktualisiere_und_schaetze(
z["id"], z.get("status") or "", z.get("progress") or 0,
jetzt, cache_get, cache_set,
@@ -790,6 +821,7 @@ async def get_job_detail(job_id: str):
detail = _job_row_to_model(job).dict()
detail["target_dir"] = job.get("target_dir")
detail["output_path"] = job.get("output_path")
detail["retry_art"] = phasen.retry_art(job)
try:
detail["meta"] = json.loads(job["meta"]) if job.get("meta") else None
except ValueError:
@@ -1024,6 +1056,86 @@ async def retry_transcode(job_id: str):
return {"id": job_id, "status": "transcoding"}
@app.post("/jobs/{job_id}/retry-rip", status_code=201)
async def retry_rip(job_id: str):
"""Rippt die Disc dieses Jobs NOCH EINMAL — als frischer Job.
Der Gegenpart zu `retry-transcode`. Nötig, weil es für einen mitten im Rip
gestorbenen Job vorher überhaupt keinen richtigen Knopf gab: Das UI bot nur
„Neu" an, und das war immer die Kompression. Am 26.07.2026 hätte das aus
5,1 GB Bruchstück brav einen Film gemacht, der bei 12 % aufhört.
Bewusst ein NEUER Job mit neuer ID, nicht ein Wiederbeleben des alten:
* Das Roh-Verzeichnis heißt <Arbeitsverzeichnis>/<job_id>. Bei gleicher ID
läge das alte Bruchstück im neuen Verzeichnis, und die Kompression
sammelt am Ende ALLE MKV-Dateien darin ein — sie würde das Bruchstück
mitverarbeiten.
* Der Fehlschlag bleibt in der Liste nachlesbar, statt überschrieben zu
werden.
Übernommen werden Titel, Ablageziel und alle Metadaten des alten Jobs
(Jahr/Poster, Titel-Auswahl, Sprachwunsch, Arbeitsverzeichnis, gewählter
Encoder-Worker) — der Nutzer soll seine Wahl nicht neu treffen müssen. Die
Phasen-Marke wird NICHT übernommen; die setzt der Rip selbst.
"""
job = await asyncio.to_thread(db.get_job, job_id)
if not job:
raise HTTPException(status_code=404, detail="Job nicht gefunden")
if job["status"] in ("running", "pending", "transcoding", "canceling"):
raise HTTPException(status_code=409, detail="Dieser Job läuft noch")
device_path = job.get("device")
vorhandene = device_discovery.list_optical_devices()
if not device_path or device_path not in vorhandene:
raise HTTPException(
status_code=409,
detail=(
f"Das Laufwerk dieses Jobs ({device_path or 'unbekannt'}) ist nicht "
'mehr da. Disc in ein vorhandenes Laufwerk legen und den Rip über '
'„Rip starten" neu anstoßen.'
),
)
try:
status = await asyncio.to_thread(drive_status, device_path)
except OSError as e:
raise HTTPException(status_code=409, detail=f"Laufwerk {device_path} antwortet nicht: {e}")
if status != CDS_DISC_OK:
raise HTTPException(
status_code=409,
detail=(
f'Es liegt keine (lesbare) Disc in {device_path}. Ein neuer Rip '
'braucht die Disc — sie wurde nach dem Fehlschlag vermutlich '
'ausgeworfen. Disc einlegen, dann noch einmal.'
),
)
try:
meta = json.loads(job.get("meta") or "{}")
except ValueError:
meta = {}
if not isinstance(meta, dict):
meta = {}
meta.pop(phasen.RIP_FERTIG, None)
meta_json = json.dumps(meta) if meta else None
neue_id = str(uuid.uuid4())
ziel = job.get("target_dir")
await asyncio.to_thread(
db.insert_job, neue_id, device_path, job.get("disc_type"),
job.get("title"), ziel, meta_json,
)
await asyncio.to_thread(
db.add_log, "info", "api",
f"Job {neue_id} ist der Neu-Rip von {job_id} "
f"({job.get('title') or 'ohne Titel'}) auf {device_path}. "
"Das unvollständige Rohmaterial des alten Jobs bleibt liegen — es kann "
"über den Papierkorb am alten Job mitgelöscht werden.",
)
start_rip(device_path, neue_id, ziel)
return {"id": neue_id, "status": "pending", "device": device_path, "vorher": job_id}
@app.post("/jobs/{job_id}/cancel")
async def cancel_job(job_id: str):
"""Bittet den Worker, den Job abzubrechen (kooperativ über die DB).
+76
View File
@@ -0,0 +1,76 @@
"""In welcher Phase ist ein Job gestorben — und was hilft danach wirklich?
Commander-Befund 26.07.2026: „Hier gibt es den Button neu' aber WAS wird dann
gemacht? Komprimierung? Neu Gerippt? Das muss ja je nach fehlgeschlagenem Job
eine Option anbieten."
Die Frage war berechtigt und der Knopf war falsch: Er hieß nur „Neu" und rief
immer `POST /jobs/{id}/retry-transcode` — also immer die KOMPRESSION. Für einen
abgebrochenen Transcode ist das goldrichtig (die Roh-MKV liegt vollständig da,
man spart eine Stunde Rippen). Für einen abgebrochenen RIP ist es Unsinn: Am
26.07.2026 lagen 5,1 GB von rund 40 GB da, und „Neu" hätte daraus brav einen
Film komprimiert, der bei 12 % aufhört.
## Warum es dafür eine MARKE braucht
Sobald `status = "failed"` steht, ist der vorherige Zustand fort — die Spalte
hat nur einen Wert. `zombies.war_im_rip()` im Worker kann die Phase noch sehen,
weil sie dort im Moment des Aufräumens vorliegt; die API sieht später nur noch
„failed". Deshalb schreibt der Worker die Phase in die Job-Metadaten:
* Rip-Start → `rip_fertig = false`
* Rip fertig (Übergabe an die Kompression) → `rip_fertig = true`
* Start eines Transcodes → `rip_fertig = true` (heilt Bestandsjobs mit)
## Drei Antworten, nicht zwei
Die Marke FEHLT bei jedem Job, der vor dieser Änderung lief. Diese Jobs
„rip" zu nennen wäre geraten — und würde dem Commander an einem
Bestandsjob mit 79,6 GB intakter Rohdaten das „Neu komprimieren" wegnehmen,
das dort genau richtig ist. Wer die Phase nicht kennt, sagt das: `unklar`
lässt das UI beide Wege anbieten, jeden mit einem ehrlichen Satz.
"""
import json
# Schlüssel in den Job-Metadaten. Muss identisch in worker/tasks.py stehen —
# es gibt kein geteiltes Paket zwischen den Containern.
RIP_FERTIG = "rip_fertig"
# Die drei möglichen Antworten.
NEU_KOMPRIMIEREN = "transcode"
NEU_RIPPEN = "rip"
UNKLAR = "unklar"
NICHTS = ""
def _meta(meta_json) -> dict:
"""Metadaten lesen, ohne an kaputtem JSON zu scheitern."""
if isinstance(meta_json, dict):
return meta_json
if not meta_json:
return {}
try:
gelesen = json.loads(meta_json)
except (ValueError, TypeError):
return {}
return gelesen if isinstance(gelesen, dict) else {}
def retry_art(job) -> str:
"""Was soll der Knopf an DIESEM Job anbieten?
* `NICHTS` — der Job ist nicht fehlgeschlagen, es gibt nichts zu
wiederholen.
* `NEU_KOMPRIMIEREN`— der Rip war fertig, nur die Kompression starb.
* `NEU_RIPPEN` — der Rip selbst starb; alles Rohe ist ein Bruchstück.
* `UNKLAR` — kein Vermerk (Bestandsjob), beide Wege anbieten.
"""
if (job.get("status") or "") != "failed":
return NICHTS
marke = _meta(job.get("meta")).get(RIP_FERTIG)
if marke is True:
return NEU_KOMPRIMIEREN
if marke is False:
return NEU_RIPPEN
return UNKLAR
+92 -7
View File
@@ -2,34 +2,119 @@
Der API-Key-Store, der hier früher lebte, ist mit dem Auth-Rückbau
(Commander-Entscheid 24.07.2026) entfernt — Heimnetz-only, siehe KONZEPT §10.
## Was diese Bremse ist — und was nicht
Sie soll ein Amok-Skript stoppen (Endlosschleife, tausend Anfragen pro Sekunde).
Sie ist KEIN Schutz gegen Angreifer; dafür wäre Rippy die falsche Stelle.
Daraus folgt die Zahl unten: Sie muss deutlich über dem liegen, was Rippy im
Normalbetrieb selbst verursacht. Am 26.07.2026 tat sie das nicht — und die
Folgen waren dem Commander als „wird oft neu geladen" aufgefallen.
"""
import ipaddress
import time
from collections import defaultdict
from typing import Dict
# Default Rate Limit
MAX_REQUESTS_PER_MINUTE = 100
# Wie viele Anfragen pro Minute und Client durchgehen.
#
# ⚠️ Bis zum 26.07.2026 stand hier 100 — WENIGER, als das eigene Dashboard
# braucht. Nachgerechnet an den tatsächlichen Taktgebern im UI:
#
# Dashboard (Dashboard.tsx) 5 Endpunkte alle 4 s → 75/min
# Log-Kasten (LiveLogSection.tsx) 2 Endpunkte alle 5 s → 24/min
# Laufwerke (DeviceDiscovery.tsx) 1 Endpunkt alle 5 s → 12/min
# Windows-Tray (tray.py) /jobs alle 5 s → 12/min je Worker
# ─────────
# EIN offener Tab plus ein Worker 123/min
#
# Das Limit war also im Normalbetrieb um ein Viertel überschritten: Etwa jede
# vierte Anfrage bekam 429, und weil das UI einen Fehlschlag damals als „es gibt
# keine Jobs" verbuchte, leerte sich die Liste im Sekundentakt. Zwei offene Tabs
# hätten es verdoppelt.
#
# 600/min = 10 Anfragen pro Sekunde: Platz für mehrere Tabs und Worker, während
# eine Endlosschleife (Hunderte pro Sekunde) weiterhin sofort gebremst wird.
MAX_REQUESTS_PER_MINUTE = 600
# In-Memory Rate Limit Store (in Produktion mit Redis)
rate_limit_store: Dict[str, list] = defaultdict(list)
def _ist_privat(adresse: str) -> bool:
"""Steckt hinter dieser Adresse das eigene Netz (bzw. ein Container)?"""
try:
ip = ipaddress.ip_address(adresse)
except ValueError:
return False
return ip.is_private or ip.is_loopback
def client_kennung(peer: str, weitergegeben: str = None) -> str:
"""Welcher Eimer gilt für diese Anfrage? (pure Funktion, testbar)
`peer` ist der direkte Absender, `weitergegeben` der Inhalt von
`X-Real-IP` (setzt der nginx im UI-Container, siehe ui/nginx.conf).
Warum überhaupt: Der Browser spricht nie direkt mit der API, sondern über
den nginx — für die API sah deshalb JEDE Anfahrt aus dem UI gleich aus. Ein
zweiter Tab und der Windows-Tray teilten sich den Eimer mit dem Dashboard,
obwohl es drei unabhängige Clients sind.
Der Kopfzeile wird nur geglaubt, wenn der direkte Absender aus dem privaten
Netz kommt — also unser eigener Proxy. Das ist keine Härtung gegen
Angreifer (Rippy ist Heimnetz-only, KONZEPT §10), sondern verhindert, dass
eine beliebige Kopfzeile die Bremse aushebelt.
"""
peer = (peer or "").strip()
kandidat = (weitergegeben or "").split(",")[0].strip()
if kandidat and _ist_privat(peer) and _ist_privat(kandidat):
return kandidat
return peer or "unbekannt"
# Wann wurde für einen Client zuletzt eine Rate-Limit-Meldung geschrieben?
_letzte_meldung: Dict[str, float] = {}
# Abstand zwischen zwei Meldungen pro Client.
MELDE_ABSTAND_SEKUNDEN = 60
def darf_melden(client_id: str, jetzt: float = None) -> bool:
"""Soll dieser abgewiesene Aufruf ins Log? Höchstens einmal pro Minute.
Ein 429 war bisher völlig unsichtbar — das UI verbuchte ihn als „nichts
da", und niemand erfuhr, dass die Bremse greift. Jede Abweisung zu
protokollieren wäre aber die Ecke ins Gegenteil: Genau der Fall, für den die
Bremse gebaut ist (ein Skript in einer Endlosschleife), würde damit das
Log-Fenster zumüllen. Also: eine Meldung pro Client und Minute.
"""
if jetzt is None:
jetzt = time.time()
vorher = _letzte_meldung.get(client_id, 0.0)
if jetzt - vorher < MELDE_ABSTAND_SEKUNDEN:
return False
_letzte_meldung[client_id] = jetzt
return True
def check_rate_limit(client_id: str, max_requests: int = MAX_REQUESTS_PER_MINUTE, window_seconds: int = 60) -> bool:
"""Prüfe ob Client rate-limited ist."""
current_time = time.time()
window_start = current_time - window_seconds
# Bereinige alte Einträge
rate_limit_store[client_id] = [
timestamp for timestamp in rate_limit_store[client_id]
if timestamp > window_start
]
# Prüfe ob Limit erreicht
if len(rate_limit_store[client_id]) >= max_requests:
return False
# Füge neuen Request hinzu
rate_limit_store[client_id].append(current_time)
return True
@@ -39,12 +124,12 @@ def get_rate_limit_remaining(client_id: str, max_requests: int = MAX_REQUESTS_PE
"""Hole verbleibende Requests."""
current_time = time.time()
window_start = current_time - 60
current_count = len([
timestamp for timestamp in rate_limit_store[client_id]
if timestamp > window_start
])
return max(0, max_requests - current_count)
+64
View File
@@ -0,0 +1,64 @@
"""Tests für die Phasen-Erkennung — was darf der „Neu"-Knopf anbieten?"""
import json
import phasen
def test_laufender_job_bietet_nichts():
assert phasen.retry_art({"status": "running"}) == phasen.NICHTS
assert phasen.retry_art({"status": "completed"}) == phasen.NICHTS
def test_toter_transcode_bietet_komprimieren():
job = {"status": "failed", "meta": json.dumps({"rip_fertig": True})}
assert phasen.retry_art(job) == phasen.NEU_KOMPRIMIEREN
def test_toter_rip_bietet_rippen():
"""Der Vorfall vom 26.07.2026: 5,1 GB von 40 GB. Komprimieren wäre falsch."""
job = {"status": "failed", "meta": json.dumps({"rip_fertig": False, "year": 1988})}
assert phasen.retry_art(job) == phasen.NEU_RIPPEN
def test_bestandsjob_ohne_marke_ist_unklar():
"""Jobs von VOR dieser Änderung dürfen nicht geraten werden."""
assert phasen.retry_art({"status": "failed", "meta": None}) == phasen.UNKLAR
assert phasen.retry_art({"status": "failed"}) == phasen.UNKLAR
assert phasen.retry_art(
{"status": "failed", "meta": json.dumps({"year": 1988})}
) == phasen.UNKLAR
def test_kaputtes_json_ist_unklar_statt_absturz():
"""/jobs darf an einer krummen meta-Zeile nicht scheitern."""
assert phasen.retry_art({"status": "failed", "meta": "{kein json"}) == phasen.UNKLAR
assert phasen.retry_art({"status": "failed", "meta": "[1,2]"}) == phasen.UNKLAR
assert phasen.retry_art({"status": "failed", "meta": 7}) == phasen.UNKLAR
def test_marke_wird_auch_als_dict_gelesen():
"""Der Detail-Endpunkt hat die Metadaten schon geparst."""
job = {"status": "failed", "meta": {"rip_fertig": True}}
assert phasen.retry_art(job) == phasen.NEU_KOMPRIMIEREN
def test_der_schluessel_heisst_im_worker_genauso():
"""Zwei Container, kein geteiltes Paket — die Marke muss zusammenpassen.
Ohne diese Prüfung ist ein Tippfehler in einer der beiden Dateien lautlos:
Der Worker schreibt `rip_fertig`, die API liest `ripFertig`, und JEDER Job
wäre für immer „unklar". Genau diese Sorte Auseinanderdriften hat die
Zombie-Erkennung ein Release lang blind gemacht („running" vs. „ripping").
"""
import pathlib
import re
quelle = (
pathlib.Path(__file__).resolve().parents[1] / "worker" / "tasks.py"
).read_text(encoding="utf-8")
# Der Worker schreibt die Marke über eine Konstante — deren Wert muss hier
# ankommen.
treffer = re.search(r'^RIP_FERTIG\s*=\s*"([^"]+)"', quelle, re.MULTILINE)
assert treffer, "worker/tasks.py definiert RIP_FERTIG nicht mehr"
assert treffer.group(1) == phasen.RIP_FERTIG
+90
View File
@@ -0,0 +1,90 @@
"""Tests für die Anfrage-Bremse.
Sie hatte bis zum 26.07.2026 keine — und dabei war sie seit Wochen die Ursache
eines gemeldeten Fehlers („dieser Bereich wird oft neu geladen"): Das Limit lag
mit 100/min UNTER dem, was Rippys eigenes Dashboard verursacht, und weil hinter
dem nginx alle Clients dieselbe Adresse hatten, galt es auch noch gemeinsam.
"""
import ratelimit
def test_hinter_dem_proxy_zaehlt_der_echte_client():
"""Der ganze Grund für den Fehler: ein Eimer für alle."""
assert ratelimit.client_kennung("172.19.0.6", "192.168.178.20") == "192.168.178.20"
assert ratelimit.client_kennung("172.19.0.6", "192.168.178.99") == "192.168.178.99"
# Zwei Clients hinter demselben Proxy sind zwei Eimer.
assert (
ratelimit.client_kennung("172.19.0.6", "192.168.178.20")
!= ratelimit.client_kennung("172.19.0.6", "192.168.178.99")
)
def test_ohne_kopfzeile_gilt_der_direkte_absender():
assert ratelimit.client_kennung("192.168.178.20", None) == "192.168.178.20"
assert ratelimit.client_kennung("192.168.178.20", "") == "192.168.178.20"
def test_erste_adresse_der_kette_gewinnt():
"""X-Forwarded-For kann eine Liste sein — der Ursprung steht vorn."""
assert ratelimit.client_kennung(
"172.19.0.6", "192.168.178.20, 172.19.0.6"
) == "192.168.178.20"
def test_muell_in_der_kopfzeile_wird_ignoriert():
"""Sonst könnte ein Tippfehler beliebig viele Eimer aufmachen."""
assert ratelimit.client_kennung("172.19.0.6", "nicht-ip") == "172.19.0.6"
assert ratelimit.client_kennung("172.19.0.6", "<script>") == "172.19.0.6"
def test_oeffentliche_adresse_in_der_kopfzeile_wird_nicht_geglaubt():
"""Heimnetz-only: Was nicht aus dem privaten Netz kommt, zählt nicht.
Keine Härtung gegen Angreifer (dafür ist Rippy die falsche Stelle), aber die
Bremse soll sich nicht mit einer beliebigen Kopfzeile aushebeln lassen.
"""
assert ratelimit.client_kennung("172.19.0.6", "8.8.8.8") == "172.19.0.6"
def test_ohne_jede_angabe_bleibt_ein_eimer_uebrig():
assert ratelimit.client_kennung("", None) == "unbekannt"
def test_limit_greift_erst_nach_der_grenze():
ratelimit.reset_rate_limit("test-a")
for i in range(10):
assert ratelimit.check_rate_limit("test-a", max_requests=10), f"Anfrage {i}"
assert not ratelimit.check_rate_limit("test-a", max_requests=10)
def test_eimer_sind_getrennt():
ratelimit.reset_rate_limit("test-b")
ratelimit.reset_rate_limit("test-c")
for _ in range(5):
ratelimit.check_rate_limit("test-b", max_requests=5)
assert not ratelimit.check_rate_limit("test-b", max_requests=5)
assert ratelimit.check_rate_limit("test-c", max_requests=5)
def test_grenze_deckt_die_eigene_last_ab():
"""Die Bremse darf Rippy nicht selbst ausbremsen.
Nachgerechnet aus den Taktgebern im UI (Zahlen im Kopf von ratelimit.py):
Dashboard 75/min + Log-Kasten 24/min + Laufwerke 12/min + Tray 12/min je
Worker = 123/min für EINEN Tab. Mit 100 war das garantiert rot. Diese Prüfung
hält fest, dass die Grenze mit Luft darüber liegt — wer den Wert senkt, muss
hier vorbei.
"""
eigene_last_pro_tab = 75 + 24 + 12
tray_pro_worker = 12
assert ratelimit.MAX_REQUESTS_PER_MINUTE >= 2 * eigene_last_pro_tab + 3 * tray_pro_worker
def test_meldung_wird_gedrosselt():
"""Ein Amok-Skript darf nicht das Log-Fenster fluten."""
ratelimit._letzte_meldung.pop("test-d", None)
assert ratelimit.darf_melden("test-d", jetzt=1000.0)
assert not ratelimit.darf_melden("test-d", jetzt=1001.0)
assert not ratelimit.darf_melden("test-d", jetzt=1059.0)
assert ratelimit.darf_melden("test-d", jetzt=1061.0)