From bfb13f44a5e4a32ee8e7267f10cb7b07e3d34172 Mon Sep 17 00:00:00 2001 From: Hitonabi Date: Sun, 26 Jul 2026 16:49:10 +0200 Subject: [PATCH] fix(ui+api): das "Neuladen" war HTTP 429 - und "Neu" komprimierte immer 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 --- docker/api/main.py | 116 ++++++++++++++- docker/api/phasen.py | 76 ++++++++++ docker/api/ratelimit.py | 99 ++++++++++++- docker/api/test_phasen.py | 64 +++++++++ docker/api/test_ratelimit.py | 90 ++++++++++++ docker/ui/nginx.conf | 9 ++ docker/ui/src/components/RetryDialog.tsx | 165 +++++++++++++++++++++ docker/ui/src/lib/api.ts | 7 +- docker/ui/src/pages/Dashboard.tsx | 174 ++++++++++++++++++----- docker/worker/db.py | 32 +++++ docker/worker/tasks.py | 21 +++ 11 files changed, 811 insertions(+), 42 deletions(-) create mode 100644 docker/api/phasen.py create mode 100644 docker/api/test_phasen.py create mode 100644 docker/api/test_ratelimit.py create mode 100644 docker/ui/src/components/RetryDialog.tsx diff --git a/docker/api/main.py b/docker/api/main.py index e600380..6dc8271 100644 --- a/docker/api/main.py +++ b/docker/api/main.py @@ -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 /. 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). diff --git a/docker/api/phasen.py b/docker/api/phasen.py new file mode 100644 index 0000000..46d45e3 --- /dev/null +++ b/docker/api/phasen.py @@ -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 diff --git a/docker/api/ratelimit.py b/docker/api/ratelimit.py index bd7cb74..90dec21 100644 --- a/docker/api/ratelimit.py +++ b/docker/api/ratelimit.py @@ -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) diff --git a/docker/api/test_phasen.py b/docker/api/test_phasen.py new file mode 100644 index 0000000..8c29016 --- /dev/null +++ b/docker/api/test_phasen.py @@ -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 diff --git a/docker/api/test_ratelimit.py b/docker/api/test_ratelimit.py new file mode 100644 index 0000000..9fe0a54 --- /dev/null +++ b/docker/api/test_ratelimit.py @@ -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", "