fix: KI-Backend brach lange Anfragen durch PHP-Timeout ab
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:
adminandClaude Sonnet 5 committed 2026-09-21 00:27:52 +02:00
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
+2 -1
View File
@@ -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.");
+17
View File
@@ -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`,
+9
View File
@@ -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) {