fix: KI-Backend brach lange Anfragen durch PHP-Timeout ab
CI / build-and-test (push) Canceled after 0s
CI / build-and-test (push) Canceled after 0s
ai_backend_call_and_charge (db.php) hob PHPs eigenes max_execution_time
nie an, obwohl AnthropicProvider dem curl-Aufruf bis zu 180s erlaubt.
Auf vielen Hosts liegt PHPs Default bei nur 30s - sobald ein Aufruf
laenger brauchte, killte PHP das Skript mitten in curl_exec() mit einem
unabfangbaren Fatal Error statt einer sauberen {"error": "..."}-Antwort.
Der Desktop-Client zeigte dadurch nur die pauschale Fallback-Meldung
ohne Grund - sah wie ein kompletter Ausfall der Einzelstunden-KI-Planung
aus, war aber ein request-Dauer-abhaengiger Timeout, der jeden der fuenf
ai_backend_call_and_charge-Endpunkte treffen konnte.
Fix zentral in ai_backend_call_and_charge (set_time_limit(200)) statt in
jedem Endpunkt einzeln - nach Deployment vom Nutzer verifiziert. Zusaetzlich
dauerhaftes Diagnose-Logging in AiPlanningService (Statuscode/Dauer/
Antwort-Body bei Fehlern ohne auswertbaren Grund) fuer kuenftige Faelle.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
f0c0479bcb
commit
30f0fee0b2
6 files changed
+63
-9
No files matched your search
@@ -18,7 +18,7 @@ public sealed class AiPlanningServiceTests
|
||||
FakeCompetencyDomains competencyDomains, FakeAlternativeLessonPaths altPaths,
|
||||
FakeCompetencyCatalogVersions? competencyVersions = null) =>
|
||||
new(new HttpClient(), lessons, groups, subjects, competencyDomains,
|
||||
competencyVersions ?? new FakeCompetencyCatalogVersions(), altPaths);
|
||||
competencyVersions ?? new FakeCompetencyCatalogVersions(), altPaths, TestSupport.BuildAppLogger());
|
||||
|
||||
[Fact]
|
||||
public void ParsePlanningLessons_AkzeptiertNormaleAntwortUndDeutschesDatum()
|
||||
@@ -92,7 +92,8 @@ public sealed class AiPlanningServiceTests
|
||||
BaseAddress = new Uri("https://example.invalid/"),
|
||||
};
|
||||
var service = new AiPlanningService(http, new FakeLessons(), new FakeGroups([]), new FakeSubjects([]),
|
||||
new FakeCompetencyDomains(), new FakeCompetencyCatalogVersions(), new FakeAlternativeLessonPaths([]));
|
||||
new FakeCompetencyDomains(), new FakeCompetencyCatalogVersions(), new FakeAlternativeLessonPaths([]),
|
||||
TestSupport.BuildAppLogger());
|
||||
|
||||
var error = await Assert.ThrowsAsync<AiBackendException>(() =>
|
||||
service.RequestPlanAsync(new Unit(), "", "token"));
|
||||
@@ -191,7 +192,8 @@ public sealed class AiPlanningServiceTests
|
||||
BaseAddress = new Uri("https://example.invalid/"),
|
||||
};
|
||||
return new AiPlanningService(http, new FakeLessons(), new FakeGroups([]), new FakeSubjects([]),
|
||||
new FakeCompetencyDomains(), new FakeCompetencyCatalogVersions(), new FakeAlternativeLessonPaths([]));
|
||||
new FakeCompetencyDomains(), new FakeCompetencyCatalogVersions(), new FakeAlternativeLessonPaths([]),
|
||||
TestSupport.BuildAppLogger());
|
||||
}
|
||||
|
||||
private sealed class StaticResponseHandler(HttpStatusCode status, string body) : HttpMessageHandler
|
||||
|
||||
@@ -30,7 +30,8 @@ public static class TestSupport
|
||||
/// solange AiSettingsService.IsLoggedIn false ist.
|
||||
public static AiPlanningService BuildAiPlanningService() => new(
|
||||
new HttpClient(), new FakeLessons(), new FakeGroups([]), new FakeSubjects([]),
|
||||
new FakeCompetencyDomains(), new FakeCompetencyCatalogVersions(), new FakeAlternativeLessonPaths([]));
|
||||
new FakeCompetencyDomains(), new FakeCompetencyCatalogVersions(), new FakeAlternativeLessonPaths([]),
|
||||
BuildAppLogger());
|
||||
|
||||
/// Analog zu <see cref="BuildAiSettingsService"/>, eigenes Temp-Verzeichnis je Aufruf.
|
||||
public static McpSettingsService BuildMcpSettingsService()
|
||||
|
||||
@@ -254,7 +254,8 @@ public sealed class HazardAssessmentWizardViewModelTests
|
||||
aiSettings.SetEnabled(true);
|
||||
aiSettings.SetCredentialsAndToken("sebastian", "token-abc");
|
||||
var ai = new AiPlanningService(new HttpClient(), new FakeLessons(), new FakeGroups([]),
|
||||
new FakeSubjects([]), new FakeCompetencyDomains(), new FakeCompetencyCatalogVersions(), new FakeAlternativeLessonPaths([]));
|
||||
new FakeSubjects([]), new FakeCompetencyDomains(), new FakeCompetencyCatalogVersions(), new FakeAlternativeLessonPaths([]),
|
||||
TestSupport.BuildAppLogger());
|
||||
var unit = new Unit { Title = "Redoxreaktionen" };
|
||||
var lesson = new Lesson { Topic = "Natrium in Wasser" };
|
||||
|
||||
|
||||
@@ -74,7 +74,7 @@ public class GermanTimeOnlyJsonConverter : JsonConverter<TimeOnly>
|
||||
public class AiPlanningService(HttpClient http, ILessonRepository lessons,
|
||||
IGroupRepository groups, ISubjectRepository subjects,
|
||||
ICompetencyDomainRepository competencyDomains, ICompetencyCatalogVersionRepository competencyVersions,
|
||||
IAlternativeLessonPathRepository altPaths)
|
||||
IAlternativeLessonPathRepository altPaths, AppLogger logger)
|
||||
{
|
||||
// Wire-Format zum PHP-Backend ist camelCase (siehe ai-backend/) — beide Seiten sind hier
|
||||
// im eigenen Zugriff, daher bewusst konsistent camelCase statt der C#-üblichen PascalCase-Defaults.
|
||||
@@ -93,19 +93,33 @@ public class AiPlanningService(HttpClient http, ILessonRepository lessons,
|
||||
/// Websuche in substance.php (falscher Tool-Bezeichner, fehlender Beta-Header o.ä.) der
|
||||
/// eigentliche Grund nirgends sichtbar, nur "Die Anfrage ist fehlgeschlagen".
|
||||
/// </summary>
|
||||
private static async Task<AiBackendException> BuildRequestFailedExceptionAsync(HttpResponseMessage resp)
|
||||
private async Task<AiBackendException> BuildRequestFailedExceptionAsync(HttpResponseMessage resp)
|
||||
{
|
||||
string? backendReason = null;
|
||||
string? rawResponse = null;
|
||||
var responseText = await resp.Content.ReadAsStringAsync();
|
||||
try
|
||||
{
|
||||
var responseText = await resp.Content.ReadAsStringAsync();
|
||||
var body = JsonSerializer.Deserialize<BackendErrorResult>(responseText, JsonOptions);
|
||||
backendReason = string.IsNullOrWhiteSpace(body?.Error) ? null : body!.Error;
|
||||
rawResponse = string.IsNullOrWhiteSpace(body?.RawResponse) ? null : body!.RawResponse;
|
||||
}
|
||||
catch { /* Antwortkörper war kein valides {"error": "..."}-JSON - Fallback unten greift. */ }
|
||||
|
||||
// Diagnose für "sofortige, grundlose" Fehlschläge (2026-09-21): das Backend selbst liefert
|
||||
// bei jedem eigenen Fehlerpfad ein {"error": "..."} zurück (ai_backend_fail) - landet der
|
||||
// Fallback unten trotzdem ohne Grund, kam die Antwort nie bei plan.php an (Proxy/Webserver-
|
||||
// Fehlerseite, z.B. 413 Payload Too Large) oder PHP ist mitten im Request abgestürzt. Ohne
|
||||
// Content-Type/Statuscode/Rohtext lässt sich das von hier aus nicht unterscheiden.
|
||||
if (backendReason is null)
|
||||
{
|
||||
logger.Warn("AiPlanningService: Backend-Fehler ohne auswertbaren Grund. " +
|
||||
$"Status={(int)resp.StatusCode} {resp.StatusCode}, " +
|
||||
$"Content-Type={resp.Content.Headers.ContentType?.MediaType ?? "(keiner)"}, " +
|
||||
$"Body-Länge={responseText.Length}, " +
|
||||
$"Body (erste 500 Zeichen)={responseText[..Math.Min(500, responseText.Length)]}");
|
||||
}
|
||||
|
||||
return new AiBackendException(backendReason is null
|
||||
? "Die Anfrage an den KI-Dienst ist fehlgeschlagen."
|
||||
: $"Die Anfrage an den KI-Dienst ist fehlgeschlagen: {backendReason}", rawResponse);
|
||||
@@ -354,19 +368,29 @@ public class AiPlanningService(HttpClient http, ILessonRepository lessons,
|
||||
Content = JsonContent.Create(request, options: JsonOptions),
|
||||
};
|
||||
req.Headers.Authorization = new("Bearer", token);
|
||||
var requestBytes = await req.Content!.ReadAsByteArrayAsync();
|
||||
|
||||
var stopwatch = System.Diagnostics.Stopwatch.StartNew();
|
||||
HttpResponseMessage resp;
|
||||
try { resp = await http.SendAsync(req); }
|
||||
catch (TaskCanceledException)
|
||||
{
|
||||
logger.Warn($"AiPlanningService: plan.php-Anfrage nach {stopwatch.ElapsedMilliseconds}ms " +
|
||||
$"abgebrochen (Client-Timeout), Request-Größe={requestBytes.Length} Bytes.");
|
||||
throw new AiBackendException(
|
||||
"Die KI-Anfrage hat länger als 3½ Minuten gedauert und wurde beendet. " +
|
||||
"Falls das wiederholt passiert, bitte das Zeitlimit des Webserver-Proxys prüfen.");
|
||||
}
|
||||
catch (HttpRequestException)
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
logger.Warn($"AiPlanningService: plan.php-Anfrage nach {stopwatch.ElapsedMilliseconds}ms " +
|
||||
$"ohne Antwort gescheitert, Request-Größe={requestBytes.Length} Bytes: {ex.Message}");
|
||||
throw new AiBackendException("Der KI-Dienst ist nicht erreichbar. Bitte Internetverbindung prüfen.");
|
||||
}
|
||||
stopwatch.Stop();
|
||||
if (!resp.IsSuccessStatusCode)
|
||||
logger.Warn($"AiPlanningService: plan.php antwortete nach {stopwatch.ElapsedMilliseconds}ms " +
|
||||
$"mit {(int)resp.StatusCode} {resp.StatusCode}, Request-Größe={requestBytes.Length} Bytes.");
|
||||
|
||||
if (resp.StatusCode == HttpStatusCode.Unauthorized)
|
||||
throw new AiBackendException("Anmeldung abgelaufen. Bitte in den Einstellungen erneut anmelden.");
|
||||
|
||||
@@ -2971,6 +2971,23 @@ folgenden Punkte gehören direkt in `LehrerApp.Desktop`:
|
||||
schreibbar, damit auch ein extern (z.B. in Claude Desktop) formulierter Prompt zur
|
||||
Wiederverwendung gespeichert werden kann, nicht nur ein vom eigenen KI-Backend erzeugter.
|
||||
|
||||
**Ebenfalls behoben (2026-09-21, Nutzer-Feedback: "keine KI-Planung mehr zu einer einzelnen
|
||||
Stunde"):** `ai_backend_call_and_charge` (`db.php`) rief den LLM-Provider auf, ohne PHPs eigenes
|
||||
`max_execution_time` anzuheben. `AnthropicProvider` erlaubt dem curl-Aufruf bis zu 180s, aber auf
|
||||
vielen Hosts liegt PHPs Default bei nur 30s — sobald ein Aufruf einmal länger brauchte (hier:
|
||||
erstmaliger Aufruf mit gefülltem `planningIdeas`, mehr Kontext im Prompt), killte PHP das Skript
|
||||
mitten in `curl_exec()` mit einem unabfangbaren Fatal Error. Der Client bekam dadurch keine gültige
|
||||
`{"error": "..."}`-Antwort mehr und zeigte nur die pauschale Fallback-Meldung aus
|
||||
`AiPlanningService.BuildRequestFailedExceptionAsync` — sah wie ein kompletter Ausfall der
|
||||
Einzelstunden-KI-Planung aus, war aber ein request-Dauer-abhängiger Timeout, der jeden der fünf
|
||||
`ai_backend_call_and_charge`-Endpunkte treffen konnte (`plan.php`, `explain.php`,
|
||||
`untis-status.php`, `gbu.php`, `pdf-template.php`). Fix zentral in `ai_backend_call_and_charge`
|
||||
selbst (`set_time_limit(200)` vor dem Provider-Aufruf) statt in jedem Endpunkt einzeln — nach
|
||||
Deployment auf `backapi.science-teaching.de` vom Nutzer verifiziert, die zuvor fehlschlagende
|
||||
Einzelstunden-Anfrage lief danach durch. Zusätzlich dauerhaft nützliches Diagnose-Logging in
|
||||
`AiPlanningService` ergänzt (Statuscode/Dauer/Antwort-Body bei Fehlern ohne auswertbaren Grund),
|
||||
für den Fall, dass ein ähnliches Problem an anderer Stelle wieder auftritt.
|
||||
|
||||
**Wichtige Abweichung von der ursprünglichen Planung (5.2):** Vor der Umsetzung zeigte sich,
|
||||
dass 5.2 wie ursprünglich beschrieben eine zweite, parallele Fehlzeiten-Erfassung neben dem
|
||||
bereits bestehenden Anwesenheits-Tracking aus Kapitel 3 (`ParticipationEntry.Attendance`,
|
||||
|
||||
@@ -136,6 +136,15 @@ function ai_backend_call_and_charge(PDO $pdo, array $config, array $user, string
|
||||
$provider = new AnthropicProvider($config['anthropic']['api_key'], $modelKey);
|
||||
}
|
||||
|
||||
// AnthropicProvider erlaubt dem LLM-Aufruf per curl bis zu 180s (siehe dort) — PHPs eigenes
|
||||
// max_execution_time-Limit (auf vielen Hosts standardmäßig nur 30s) läuft davon unbeeinflusst
|
||||
// weiter und killt das Skript sonst mitten im curl_exec() mit einem unabfangbaren Fatal Error,
|
||||
// sobald ein Aufruf einmal länger braucht als das Hosting-Default. Das ergibt beim Client keine
|
||||
// lesbare Fehlermeldung (kein JSON-Body mehr), nur den pauschalen Fallback in
|
||||
// AiPlanningService.BuildRequestFailedExceptionAsync. Deshalb hier großzügig über die 180s der
|
||||
// Provider-Seite hinaus verlängern, statt das Limit stillschweigend greifen zu lassen.
|
||||
set_time_limit(200);
|
||||
|
||||
try {
|
||||
$result = $provider->sendMessage($systemPrompt, $userContent, $config['max_output_tokens']);
|
||||
} catch (RuntimeException $e) {
|
||||
|
||||
Reference in new issue
Block a user