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>
Dieser Commit ist enthalten in:
215
tests/test_bedrock_wiederholung.py
Normale Datei
215
tests/test_bedrock_wiederholung.py
Normale Datei
@@ -0,0 +1,215 @@
|
||||
"""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)
|
||||
In neuem Issue referenzieren
Einen Benutzer sperren