fix(ui+api): das "Neuladen" war HTTP 429 - und "Neu" komprimierte immer
Ampel / ampel (push) Successful in 30s
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:
+114
-2
@@ -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).
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user