"""Testet die Wiederholung des EU-Modellwegs bei Kapazitaetsengpaessen. Hintergrund: Am 01.08.2026 lieferte Bedrock waehrend eines Laufs ServiceUnavailableException (HTTP 503, keine freie Kapazitaet). Botocore gab nach rund fuenf Sekunden auf, die Web-Source-Selektion fiel ersatzlos aus, und im Bericht war davon nichts zu sehen. Gemessen waren es zwei betroffene Aufrufe bei rund 340, also 0,6 Prozent. Geprueft werden die vier Zusagen des Umbaus: 1. Kapazitaets- und Kontingentfehler werden mit wachsendem Abstand wiederholt. 2. Die Wartezeit passt in das Zeitbudget des Aufrufs und in das des Laufs. 3. Beide Ursachen werden im Log auseinandergehalten. 4. Ein Lauf mit Aussetzern ist im Refresh-Protokoll erkennbar. Laeuft ohne Netzzugriff, ohne Kosten und ohne AWS-Zugang: der Converse-Aufruf ist durch ein Testdouble ersetzt, Wartezeiten werden nicht real abgewartet. Aufruf aus dem Projektstamm: venv/bin/python tests/test_bedrock_wiederholung.py """ import asyncio import os import sys sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "src")) import agents.bedrock_client as bc # noqa: E402 from agents.claude_client import ClaudeCliError # noqa: E402 ok = 0 fail = 0 def pruefe(name, bedingung, extra=""): global ok, fail if bedingung: ok += 1 print(" OK " + name) else: fail += 1 print(" FEHL " + name + " " + str(extra)) # --------------------------------------------------------------------------- # Testdoubles # --------------------------------------------------------------------------- class FakeFehler(Exception): """Botocore-aehnlicher Fehler mit response-Dict.""" def __init__(self, code): super().__init__(f"{code}: Testfehler") self.response = {"Error": {"Code": code}} ANTWORT = { "output": {"message": {"content": [{"text": '{"ok": true}'}]}}, "stopReason": "end_turn", "usage": {"inputTokens": 10, "outputTokens": 5}, "metrics": {"latencyMs": 1200}, } aufrufe = {"n": 0} gewartet = [] antwortfolge = [] class FakeClient: def converse(self, **kwargs): aufrufe["n"] += 1 naechste = antwortfolge.pop(0) if antwortfolge else ANTWORT if isinstance(naechste, Exception): raise naechste return naechste async def fake_sleep(sekunden): gewartet.append(sekunden) def neu(folge, budget=None, waits=(5, 15, 30)): """Setzt Testdoubles und Kontext fuer einen Durchgang.""" aufrufe["n"] = 0 gewartet.clear() antwortfolge[:] = list(folge) bc.BEDROCK_RETRY_WAITS[:] = list(waits) bc.refresh_kontext_starten() if budget is not None: bc._wartebudget_var.set([budget]) bc._get_client = lambda: FakeClient() bc.asyncio.sleep = fake_sleep def lauf(timeout=420.0): return asyncio.run(bc.call_bedrock("Testauftrag", timeout=timeout)) # --------------------------------------------------------------------------- # A. Störungsarten auseinanderhalten # --------------------------------------------------------------------------- print("\nA. Ursachen unterscheiden") pruefe("A1 503 ist ein Kapazitaetsengpass", bc.stoerungsart(FakeFehler("ServiceUnavailableException")) == "kapazitaet") pruefe("A2 429 ist unser Kontingent", bc.stoerungsart(FakeFehler("ThrottlingException")) == "kontingent") pruefe("A3 Netzabbruch ist eine Verbindungsstoerung", bc.stoerungsart(type("ReadTimeoutError", (Exception,), {})()) == "verbindung") pruefe("A4 Auth-Fehler wird nicht wiederholt", bc.stoerungsart(FakeFehler("AccessDeniedException")) is None) pruefe("A5 beide Ursachen bleiben nach aussen 'rate_limit'", bc._classify_bedrock_error(FakeFehler("ServiceUnavailableException")) == "rate_limit" and bc._classify_bedrock_error(FakeFehler("ThrottlingException")) == "rate_limit") # --------------------------------------------------------------------------- # B. Wiederholung # --------------------------------------------------------------------------- print("\nB. Wiederholung bei Kapazitaetsengpass") neu([FakeFehler("ServiceUnavailableException"), ANTWORT]) text, usage = lauf() pruefe("B1 zweiter Versuch liefert das Ergebnis", aufrufe["n"] == 2, aufrufe["n"]) pruefe("B2 dazwischen wurde 5 Sekunden gewartet", gewartet == [5], gewartet) pruefe("B3 Verbrauch wird normal zurueckgegeben", usage.input_tokens == 10, usage) neu([FakeFehler("ServiceUnavailableException")] * 3 + [ANTWORT]) lauf() pruefe("B4 Staffelung 5, 15, 30 Sekunden", gewartet == [5, 15, 30], gewartet) pruefe("B5 vier Versuche insgesamt", aufrufe["n"] == 4, aufrufe["n"]) neu([FakeFehler("ServiceUnavailableException")] * 5) try: lauf() pruefe("B6 nach der letzten Stufe wird aufgegeben", False, "kein Fehler geworfen") except ClaudeCliError as e: pruefe("B6 nach der letzten Stufe wird aufgegeben", e.error_type == "rate_limit", e.error_type) pruefe("B7 genau vier Versuche, dann Schluss", aufrufe["n"] == 4, aufrufe["n"]) neu([FakeFehler("AccessDeniedException"), ANTWORT]) try: lauf() pruefe("B8 Auth-Fehler wird sofort durchgereicht", False, "kein Fehler geworfen") except ClaudeCliError as e: pruefe("B8 Auth-Fehler wird sofort durchgereicht", e.error_type == "auth_error" and aufrufe["n"] == 1, (e.error_type, aufrufe["n"])) # --------------------------------------------------------------------------- # C. Budgets # --------------------------------------------------------------------------- print("\nC. Zeitbudget und Wartebudget") # Planungsaufrufe an Haiku haben nur 120 Sekunden. Nach zwei Wartestufen # (5 + 15) ist zu wenig Rest fuer die dritte (30 + 12 Reserve). neu([FakeFehler("ServiceUnavailableException")] * 5) try: lauf(timeout=40.0) except ClaudeCliError: pass pruefe("C1 Wartezeit passt sich dem Zeitbudget des Aufrufs an", gewartet == [5, 15], gewartet) neu([FakeFehler("ServiceUnavailableException")] * 5, budget=10.0) try: lauf() except ClaudeCliError: pass pruefe("C2 Wartebudget des Laufs begrenzt die Wiederholung", gewartet == [5], gewartet) neu([FakeFehler("ServiceUnavailableException"), ANTWORT], budget=25.0) lauf() pruefe("C3 verbrauchte Wartezeit wird vom Budget abgezogen", abs(bc._budget_rest() - 20.0) < 0.01, bc._budget_rest()) # --------------------------------------------------------------------------- # D. Sichtbarkeit # --------------------------------------------------------------------------- print("\nD. Sichtbarkeit im Refresh-Protokoll") neu([FakeFehler("ServiceUnavailableException"), FakeFehler("ThrottlingException"), ANTWORT]) meldungen = [] bc.wartemelder_setzen(lambda art, sek: meldungen.append((art, sek))) lauf() hinweis = bc.stoerungen_zusammenfassen() pruefe("D1 Hinweis nennt beide Ursachen", "Kapazität" in hinweis and "Kontingent" in hinweis, hinweis) pruefe("D2 Hinweis nennt die gesamte Wartezeit", "20s" in hinweis, hinweis) pruefe("D3 Oberflaeche wird waehrend jeder Wartezeit benachrichtigt", meldungen == [("kapazitaet", 5), ("kontingent", 15)], meldungen) pruefe("D4 Hinweis nutzt echte Umlaute", "Kapazitaet" not in hinweis and "ausgeschoepft" not in hinweis, hinweis) neu([ANTWORT]) bc.wartemelder_setzen(None) lauf() pruefe("D5 stoerungsfreier Lauf erzeugt keinen Hinweis", bc.stoerungen_zusammenfassen() == "", bc.stoerungen_zusammenfassen()) pruefe("D6 stoerungsfreier Lauf wartet nicht", gewartet == [], gewartet) # Eine kaputte Anzeige darf den Lauf nicht gefaehrden. neu([FakeFehler("ServiceUnavailableException"), ANTWORT]) def _melder_mit_fehler(art, sek): raise RuntimeError("Anzeige kaputt") bc.wartemelder_setzen(_melder_mit_fehler) text, _ = lauf() pruefe("D7 Fehler in der Anzeige bricht den Lauf nicht ab", text.startswith("{"), text[:40]) bc.wartemelder_setzen(None) print("\nErgebnis: " + str(ok) + " bestanden, " + str(fail) + " fehlgeschlagen") sys.exit(1 if fail else 0)