Skip to content

v0.2.7

Latest

Choose a tag to compare

@malkreide malkreide released this 03 Aug 12:37
973dce7

[0.2.7] - 2026-08-03

Behoben

  • Der DNS-Retry war nur gegen Mocks belegt. Die Unit-Tests faelschen
    Aufloeser und Antwort; sie zeigen, dass die Schleife tut, was sie soll,
    aber nicht, dass der Aufruf am Ende echte Daten bringt. Genau diese Luecke
    hat den Fehler ueberhaupt erst durchgelassen — gemeldet hat ihn am
    3. August 2026 ein Live-Lauf, nicht die Suite.

    Ein Live-Test faelscht jetzt nur noch den ersten Aufloesungsversuch und
    laesst alles danach echt: echtes DNS beim zweiten Versuch, echte Verbindung,
    echte BISTA-Antwort, echte Backoff-Wartezeit. Ein zweiter Live-Test prueft
    die Egress-Blocklist (SEC-005) gegen die echte Antwort des echten Hosts
    statt gegen eine erfundene.

    Zwei Nebenbefunde, beide behoben: Die DNS-Stub-Fixture in
    tests/test_retry_policy.py setzt fuer Live-Tests aus (ein «Live»-Test
    gegen einen gestubbten Aufloeser prueft das Gegenteil seines Namens), und
    der gepoolte HTTP-Client wird vor und nach jedem Live-Test frisch gesetzt:
    Seine offenen Verbindungen gehoeren dem Event-Loop, in dem sie entstanden,
    und pytest-asyncio gibt jedem Test einen eigenen. Der zweite Live-Test
    scheiterte deshalb mit «Event loop is closed» — an einem Fehler des
    Testaufbaus, der wie ein Ausfall der Quelle aussieht. Unit-Tests merken
    davon nichts, weil respx die Transport-Schicht ersetzt; latent war es,
    bis ein zweiter Live-Test dazukam.

  • Ein DNS-Zucken beendete den Tool-Aufruf sofort — und wurde als
    Egress-Verstoss gemeldet.
    _resolve_and_validate warf fuer zwei
    grundverschiedene Lagen denselben PermissionError: fuer den
    Policy-Verstoss (Host loest auf eine interne IP auf, SEC-005) und fuer
    das Scheitern von socket.getaddrinfo («Temporary failure in name
    resolution»). Die Retry-Schleife ueberspringt PermissionError bewusst —
    richtig fuer die Politik, falsch fuer den Ausfall. Bei einem Live-Lauf am
    3. August 2026 scheiterten drei Tool-Aufrufe hintereinander genau so; der
    vierte ging durch.

    Die Lagen tragen jetzt eigene Typen, EgressBlocked und
    UpstreamUnresolvable, beide weiterhin auf PermissionError als
    gemeinsamer Basis — bestehende except-Klauseln und Tests behalten damit
    ihre Bedeutung, statt still ins Leere zu laufen.

    UpstreamUnresolvable wird wiederholt, unter demselben Budget,
    derselben Versuchszahl und derselben Backoff-Kurve wie jeder andere
    Ausfall; EgressBlocked wird weiterhin nie wiederholt. Damit das Budget
    fuer diesen Pfad auch haelt, laeuft die Aufloesung im Thread-Pool statt im
    Event-Loop: getaddrinfo ist synchron, und was den Loop blockiert, kann
    die Wanduhr-Deadline nicht schneiden — aus vier Versuchen waeren sonst vier
    Blockaden ueber das Budget hinaus geworden.

    _handle_error trennt die Meldungen: Ein Aufloeser-Ausfall verweist nicht
    mehr auf die Egress-Konfiguration, wo dabei nichts zu finden ist, sondern
    sagt, dass es voruebergehend ist und ein erneuter Versuch die richtige
    Handlung. Die sanitisierte Form bleibt (OBS-002, keine str(e)-Leaks).

  • Vier von sechs Datensaetzen waren gegen die echte API kaputt: BISTA hat die
    Schreibweise der Kopfzeile gewechselt.
    Der Code las r["Schulgemeinde"],
    die Quelle liefert schulgemeinde. Der Zugriff ergab keinen Treffer,
    sondern ein leeres Ergebnis mit der Meldung «Schulgemeinde nicht gefunden» —
    ein Ausfall, der wie eine Antwort aussieht.

    Stand 3. August 2026 liefern vier der sechs genutzten Datensaetze klein,
    zwei gross, und zwei mischen innerhalb einer Kopfzeile
    (gebiet_Bezeichnung, staatsangehoerigkeit_ISO2_Code). Die Schluesselnamen
    werden deshalb beim Parsen normalisiert, statt eine Schreibweise zu waehlen,
    die schon zweimal gewechselt hat.

    Die Unit-Tests haben davon nichts gesehen: Ihre Fixtures pinnen die alte
    Kopfzeile, also blieben sie gruen. Gemeldet hat es allein der Live-Test — den
    CI per -m "not live" ausschliesst.

  • Ein Fuenftel der Zeilen liess die Tools abstuerzen. BISTA unterdrueckt
    kleine Fallzahlen aus Datenschutzgruenden und schreibt statt einer Zahl
    1 bis 5; dazu kommen NULL und leere Zellen. Betroffen waren 18.6 % der
    Sek-I- und 18.1 % der Staatsangehoerigkeits-Zeilen. int("1 bis 5") wirft,
    und der Aufrufer sah davon nur «unerwarteter interner Fehler».

    Solche Werte als 0 zu zaehlen waere die schlechtere Antwort gewesen: Die
    Summe bliebe plausibel, waere still zu tief und durch nichts als falsch
    erkennbar. Sie werden jetzt aus den Summen ausgenommen, in der Tabelle als
    1 bis 5 gezeigt, und jede betroffene Ausgabe traegt einen Hinweis, wie
    viele Zeilen fehlen und dass die echten Werte hoeher liegen (FID-003).

  • _latest_year suchte in Jahr statt jahr und lieferte darum «Keine
    Jahresdaten verfuegbar» — derselbe Drift, eine Ebene tiefer.

  • Staatsangehoerigkeit_ISO2_Code wurde nie gefunden, weil die Spalte
    gemischt geschrieben ist. Die ISO2-Spalte blieb dauerhaft auf «—».

  • Die _no_sleep-Fixture griff weiter, als sie durfte. Sie patchte
    http_client.asyncio.sleep — das sieht lokal aus, trifft aber das Modul
    asyncio und damit jeden Import im Prozess. Jeder Test, der
    asyncio.sleep(0) benutzt, um dem Event-Loop das Wort zu geben, haette
    danach still nichts mehr geprueft: Er laeuft weiter und misst nichts.

    In diesem Repo gibt es derzeit keinen solchen Test, der Schaden war also
    latent. In srgssr-mcp ist derselbe Griff in derselben Kampagne
    zugeschnappt und hat eine Parallelitaets-Pruefung entschaerft.

    Der Backoff laeuft jetzt ueber den Modul-Alias http_client._sleep, und ein
    Test haelt fest, dass asyncio.sleep intakt bleibt.

Hinzugefuegt

  • Retry-Politik gegenueber BISTA (ARCH-014). Bisher gab es keine: Ein
    einzelner Netzwerkfehler, ein Timeout oder ein 503 beendete den Tool-Aufruf,
    obwohl der naechste Versuch Sekunden spaeter geklappt haette. Genau so fielen
    am 1. August in swiss-efv-mcp vier Live-Tests wegen eines voruebergehenden
    Ausfalls der Quelle.

    Wiederholt werden Netzwerkfehler, Timeouts, 5xx und 429 — vier Versuche. Ein
    4xx ausser 429 ist eine Aussage ueber die Anfrage und keine ueber den Moment
    und scheitert weiterhin sofort. PermissionError aus dem Egress-Guard ist
    eine Policy-Entscheidung und wird nie wiederholt: Vier Mal dieselbe verbotene
    Anfrage zu stellen macht sie nicht erlaubter.

  • Retry-After wird gelesen und schlaegt die eigene Backoff-Kurve. Bei 429
    und 503 sagt die Quelle im Header, wann sie wieder mag — als Sekundenzahl
    oder als HTTP-Datum; beide Formen kommen vor, beide werden gelesen
    (RFC 9110 §10.2.3). Ein unbrauchbarer Header fuehrt zurueck auf die Kurve
    statt zum Absturz — auf dem Fehlerpfad ist das der Unterschied zwischen einer
    Verzoegerung und einem zweiten Fehler.

  • Backoff ist gestreut (Jitter). Eine reine 2**attempt-Kurve ist
    deterministisch: Faellt BISTA aus, waehrend mehrere Clients es abfragen,
    laufen deren Retries im Gleichtakt, und die Last kommt als Welle zurueck —
    genau wenn die Quelle sich erholt. Exponentielle Wartezeiten landen in
    [0.5x, 1.5x]; auf einem Retry-After ist die Streuung einseitig
    ([1.0x, 1.25x]), weil frueher wiederzukommen die Missachtung derselben
    Angabe waere, die man gerade liest. Deckel von 20 s auf jede Einzelwartezeit,
    angewandt nach dem Jittern — die andere Reihenfolge macht den Deckel zu
    gar keiner Schranke.

  • Gesamtbudget von 25 s ueber den ganzen Aufruf. Eine Versuchszahl ist
    keine Grenze: Vier Versuche a 30 s Timeout plus Backoff sind ueber zwei
    Minuten, und die Zahl 4 sagt das nirgends. Entscheidender ist, dass die
    massgebliche Grenze gar nicht uns gehoert — der Aufrufer hat sein eigenes
    Timeout, und jenseits davon hoert niemand mehr zu. Der Anker ist gemessen:
    Das Python-MCP-SDK setzt MCP_DEFAULT_TIMEOUT = 30.0.

    Das Budget haengt an einer asyncio.timeout-Deadline, nicht am
    httpx-Timeout: httpx begrenzt pro Operation, und sein Read-Timeout beginnt
    mit jedem Chunk von vorn — eine langsam troepfelnde Antwort wuerde das Budget
    sonst ueberdauern, ohne dass ein einzelner Read ablaeuft.

Behoben

  • Ein aufgebrauchtes Gesamtbudget las sich als «unerwarteter interner
    Fehler».
    Es wirft den builtin TimeoutError, _handle_error kannte aber
    nur httpx.TimeoutException. Fuer den Aufrufer ist beides dasselbe: Es hat
    zu lange gedauert.