Dateien
AegisSight-Monitor/tests/test_bedrock_wiederholung.py
claude-dev 0154c0f575 fix(eu): Kapazitaetsengpaesse bei Bedrock aussitzen statt Schritte verlieren
Am 01.08.2026 lieferte Bedrock waehrend eines Laufs ServiceUnavailableException.
Botocore versuchte es dreimal in fuenf Sekunden und gab auf, die
Web-Source-Selektion fiel ersatzlos aus. Im Bericht war davon nichts zu sehen,
der Lauf galt als vollstaendig. Gemessen sind es zwei betroffene Aufrufe bei
rund 340, also 0,6 Prozent, aber jeder Vorfall kostet einen ganzen
Pipeline-Schritt.

Der Fehler ist auch keiner unserer Quote. ServiceUnavailableException ist ein
503, AWS hat also gerade keine Kapazitaet fuer das Modell. Unsere Meldung
sagte trotzdem "Rate-Limit", weil der Code beide Faelle in einen Topf warf.

Aendert sich damit:

1. Geduldiges Wiederholen. Kapazitaets- (503), Kontingent- (429) und
   Verbindungsfehler werden nach 5, 15 und 30 Sekunden erneut versucht. Diese
   Dellen dauern typischerweise unter einer Minute, unsere bisherigen fuenf
   Sekunden lagen genau im ungeguenstigsten Fenster. Stufen ueber
   BEDROCK_RETRY_WAITS einstellbar.

2. Zwei Bremsen gegen lange Laeufe. Gewartet wird nur, wenn die Wartezeit ins
   Zeitbudget des Aufrufs passt (die Wartezeit zaehlt gegen dasselbe
   asyncio-Budget, und die Haiku-Planungsaufrufe haben nur 120 Sekunden), und
   nur solange das Wartebudget des Refreshs reicht (BEDROCK_RETRY_BUDGET_S,
   Vorgabe 120 Sekunden). Ein Lauf kann sich dadurch um hoechstens zwei
   Minuten verlaengern.

3. Ursachen werden getrennt benannt. Im Log steht jetzt "hat keine freie
   Kapazitaet" oder "meldet ausgeschoepftes Kontingent" statt pauschal
   "Rate-Limit". Nach aussen bleibt beides die Kategorie rate_limit, damit die
   Retry-Steuerung des Orchestrators unveraendert greift.

4. Stoerungen werden sichtbar. Waehrend einer Wartezeit meldet die Oberflaeche
   "KI-Dienst hat keine freie Kapazitaet, neuer Versuch in 15 Sekunden", sonst
   sieht die stehende Anzeige wie ein Haenger aus. Nach dem Lauf steht eine
   Zusammenfassung im Refresh-Protokoll, auch wenn der Lauf sonst glatt
   durchlief.

5. botocore laeuft im Modus 'adaptive' statt 'standard'. Es bremst sich bei
   Drosselung selbst ein und deckt die Sekunden ab, unsere Schleife die
   Zehnersekunden.

Zeitliche Wirkung, gemessen am EU-Lauf der Ceuta-Lage (252 Sekunden, 15
Bedrock-Aufrufe): im stoerungsfreien Fall null, im betroffenen Lauf rund eine
Minute mehr. Bei 0,6 Prozent Fehlerrate je Aufruf trifft das etwa jeden
elften Lauf, im Mittel ueber alle Laeufe rund zwei Prozent.

Gegenprobe mit einem echten Bedrock-Aufruf ueber den geaenderten Pfad: Antwort,
Verbrauch und Kosten unveraendert, keine Stoerung protokolliert.

Neu sind 23 Pruefungen, insgesamt laufen 179 ohne Netzzugriff und ohne Kosten.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 22:58:50 +02:00

216 Zeilen
7.8 KiB
Python

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