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
+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)