bfb13f44a5
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>
91 lines
3.6 KiB
Python
91 lines
3.6 KiB
Python
"""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)
|