From 44d718383072260d353a9f8fba8d10b78623cfe4 Mon Sep 17 00:00:00 2001 From: Benjamin Michaelis Date: Mon, 1 Jun 2026 14:51:45 -0700 Subject: [PATCH 1/5] fix(chat): surface streaming terminal failure details in SSE errors Propagate stream failure categories (error, failed, incomplete) via ChatBackendUnavailableException.ErrorCode and return specific message/code in both JSON and SSE error responses. --- .../Services/AIChatService.cs | 9 ++++++--- .../Services/ChatBackendUnavailableException.cs | 15 +++++++++++++-- EssentialCSharp.Web/Controllers/ChatController.cs | 5 +++-- 3 files changed, 22 insertions(+), 7 deletions(-) diff --git a/EssentialCSharp.Chat.Shared/Services/AIChatService.cs b/EssentialCSharp.Chat.Shared/Services/AIChatService.cs index bf308e37..81f0f840 100644 --- a/EssentialCSharp.Chat.Shared/Services/AIChatService.cs +++ b/EssentialCSharp.Chat.Shared/Services/AIChatService.cs @@ -251,17 +251,20 @@ private static string SanitizeForXmlContext(string? input) => else if (update is StreamingResponseErrorUpdate errorUpdate) { throw new ChatBackendUnavailableException( - $"Streaming response error: {errorUpdate.Code ?? "unknown"} - {errorUpdate.Message ?? "no message provided"}"); + $"Streaming response error: {errorUpdate.Code ?? "unknown"} - {errorUpdate.Message ?? "no message provided"}", + errorCode: "stream_response_error"); } else if (update is StreamingResponseFailedUpdate failedUpdate) { throw new ChatBackendUnavailableException( - BuildStreamingTerminalFailureMessage(failedUpdate.Response, "failed")); + BuildStreamingTerminalFailureMessage(failedUpdate.Response, "failed"), + errorCode: "stream_response_failed"); } else if (update is StreamingResponseIncompleteUpdate incompleteUpdate) { throw new ChatBackendUnavailableException( - BuildStreamingTerminalFailureMessage(incompleteUpdate.Response, "incomplete")); + BuildStreamingTerminalFailureMessage(incompleteUpdate.Response, "incomplete"), + errorCode: "stream_response_incomplete"); } // StreamingResponseCompletedUpdate: ResponseId already emitted above — no-op. } diff --git a/EssentialCSharp.Chat.Shared/Services/ChatBackendUnavailableException.cs b/EssentialCSharp.Chat.Shared/Services/ChatBackendUnavailableException.cs index 61346060..d3ee1b28 100644 --- a/EssentialCSharp.Chat.Shared/Services/ChatBackendUnavailableException.cs +++ b/EssentialCSharp.Chat.Shared/Services/ChatBackendUnavailableException.cs @@ -1,4 +1,15 @@ namespace EssentialCSharp.Chat.Common.Services; -public class ChatBackendUnavailableException(string message, Exception? innerException = null) - : Exception(message, innerException); +public class ChatBackendUnavailableException : Exception +{ + public string ErrorCode { get; } + + public ChatBackendUnavailableException( + string message, + string errorCode = "chat_unavailable", + Exception? innerException = null) + : base(message, innerException) + { + ErrorCode = string.IsNullOrWhiteSpace(errorCode) ? "chat_unavailable" : errorCode; + } +} diff --git a/EssentialCSharp.Web/Controllers/ChatController.cs b/EssentialCSharp.Web/Controllers/ChatController.cs index 7cfcb636..50150385 100644 --- a/EssentialCSharp.Web/Controllers/ChatController.cs +++ b/EssentialCSharp.Web/Controllers/ChatController.cs @@ -278,14 +278,15 @@ public async Task StreamMessage([FromBody] ChatMessageRequest request, Cancellat LogChatStreamErrorBeforeResponseStarted(_Logger, ex, User.Identity?.Name); Response.StatusCode = StatusCodes.Status503ServiceUnavailable; Response.ContentType = "application/json"; - await Response.WriteAsJsonAsync(new { error = "Chat service unavailable", errorCode = "chat_unavailable" }, cancellationToken); + await Response.WriteAsJsonAsync(new { error = ex.Message, errorCode = ex.ErrorCode }, cancellationToken); } catch (ChatBackendUnavailableException ex) { LogChatStreamErrorMidStream(_Logger, ex, User.Identity?.Name); try { - await Response.WriteAsync("data: {\"type\":\"error\",\"message\":\"Chat service unavailable\",\"errorCode\":\"chat_unavailable\"}\n\n", CancellationToken.None); + var eventData = JsonSerializer.Serialize(new { type = "error", message = ex.Message, errorCode = ex.ErrorCode }); + await Response.WriteAsync($"data: {eventData}\n\n", CancellationToken.None); await Response.Body.FlushAsync(CancellationToken.None); } catch (Exception writeException) when (writeException is IOException or OperationCanceledException or ObjectDisposedException) From 3f5b8ba38e694c958c4db3b4515a97f5fde3614a Mon Sep 17 00:00:00 2001 From: Benjamin Michaelis Date: Mon, 1 Jun 2026 14:52:04 -0700 Subject: [PATCH 2/5] fix(chat): update local backend exception call sites Adjust LocalChatService to use named innerException argument after ChatBackendUnavailableException constructor expansion. --- .../Services/LocalChatService.cs | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/EssentialCSharp.Chat.Shared/Services/LocalChatService.cs b/EssentialCSharp.Chat.Shared/Services/LocalChatService.cs index f607082d..7be84a1e 100644 --- a/EssentialCSharp.Chat.Shared/Services/LocalChatService.cs +++ b/EssentialCSharp.Chat.Shared/Services/LocalChatService.cs @@ -71,11 +71,11 @@ public void Dispose() } catch (HttpRequestException ex) { - throw new ChatBackendUnavailableException("Local AI backend is unavailable.", ex); + throw new ChatBackendUnavailableException("Local AI backend is unavailable.", innerException: ex); } catch (TaskCanceledException ex) when (!cancellationToken.IsCancellationRequested) { - throw new ChatBackendUnavailableException("Local AI backend timed out.", ex); + throw new ChatBackendUnavailableException("Local AI backend timed out.", innerException: ex); } using (response) @@ -87,15 +87,15 @@ public void Dispose() } catch (HttpRequestException ex) { - throw new ChatBackendUnavailableException("Local AI backend is unavailable while reading response.", ex); + throw new ChatBackendUnavailableException("Local AI backend is unavailable while reading response.", innerException: ex); } catch (IOException ex) { - throw new ChatBackendUnavailableException("Local AI backend connection closed while reading response.", ex); + throw new ChatBackendUnavailableException("Local AI backend connection closed while reading response.", innerException: ex); } catch (TaskCanceledException ex) when (!cancellationToken.IsCancellationRequested) { - throw new ChatBackendUnavailableException("Local AI backend timed out while reading response.", ex); + throw new ChatBackendUnavailableException("Local AI backend timed out while reading response.", innerException: ex); } if (!response.IsSuccessStatusCode) @@ -117,7 +117,7 @@ public void Dispose() } catch (Exception ex) when (ex is JsonException || ex is InvalidOperationException || ex is NotSupportedException) { - throw new ChatBackendUnavailableException("Local AI backend returned an invalid response.", ex); + throw new ChatBackendUnavailableException("Local AI backend returned an invalid response.", innerException: ex); } } } From 25c2eacd3bf78ac988f89cf95569a382cf87f946 Mon Sep 17 00:00:00 2001 From: Benjamin Michaelis Date: Mon, 1 Jun 2026 15:13:22 -0700 Subject: [PATCH 3/5] chore(chat): add structured logs for streaming terminal failures Log streaming error, failed, and incomplete terminal updates with response id, status, error code/message, and incomplete reason to make production failures diagnosable before merge. --- .../Services/AIChatService.cs | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/EssentialCSharp.Chat.Shared/Services/AIChatService.cs b/EssentialCSharp.Chat.Shared/Services/AIChatService.cs index 81f0f840..f021865d 100644 --- a/EssentialCSharp.Chat.Shared/Services/AIChatService.cs +++ b/EssentialCSharp.Chat.Shared/Services/AIChatService.cs @@ -250,18 +250,39 @@ private static string SanitizeForXmlContext(string? input) => } else if (update is StreamingResponseErrorUpdate errorUpdate) { + LogStreamingResponseErrorUpdate( + _Logger, + currentLegResponseId, + errorUpdate.Code ?? "unknown", + errorUpdate.Message ?? "no message provided"); throw new ChatBackendUnavailableException( $"Streaming response error: {errorUpdate.Code ?? "unknown"} - {errorUpdate.Message ?? "no message provided"}", errorCode: "stream_response_error"); } else if (update is StreamingResponseFailedUpdate failedUpdate) { + LogStreamingResponseTerminalUpdate( + _Logger, + "failed", + failedUpdate.Response.Id, + failedUpdate.Response.Status?.ToString(), + failedUpdate.Response.Error?.Code.ToString(), + failedUpdate.Response.Error?.Message, + failedUpdate.Response.IncompleteStatusDetails?.Reason?.ToString()); throw new ChatBackendUnavailableException( BuildStreamingTerminalFailureMessage(failedUpdate.Response, "failed"), errorCode: "stream_response_failed"); } else if (update is StreamingResponseIncompleteUpdate incompleteUpdate) { + LogStreamingResponseTerminalUpdate( + _Logger, + "incomplete", + incompleteUpdate.Response.Id, + incompleteUpdate.Response.Status?.ToString(), + incompleteUpdate.Response.Error?.Code.ToString(), + incompleteUpdate.Response.Error?.Message, + incompleteUpdate.Response.IncompleteStatusDetails?.Reason?.ToString()); throw new ChatBackendUnavailableException( BuildStreamingTerminalFailureMessage(incompleteUpdate.Response, "incomplete"), errorCode: "stream_response_incomplete"); @@ -675,6 +696,27 @@ private static string BuildStreamingTerminalFailureMessage(ResponseResult respon [LoggerMessage(Level = LogLevel.Information, Message = "AI contextual search performed for prompt enrichment")] private static partial void LogContextualSearchPerformed(ILogger logger); + [LoggerMessage( + Level = LogLevel.Warning, + Message = "Streaming response error update received: responseId={ResponseId} code={Code} message={Message}")] + private static partial void LogStreamingResponseErrorUpdate( + ILogger logger, + string? responseId, + string code, + string message); + + [LoggerMessage( + Level = LogLevel.Warning, + Message = "Streaming response terminal update: updateType={UpdateType} responseId={ResponseId} status={Status} errorCode={ErrorCode} errorMessage={ErrorMessage} incompleteReason={IncompleteReason}")] + private static partial void LogStreamingResponseTerminalUpdate( + ILogger logger, + string updateType, + string? responseId, + string? status, + string? errorCode, + string? errorMessage, + string? incompleteReason); + /// /// Returns true when the API error indicates the conversation context window was exceeded. /// Prefers structured JSON error code from the response body; falls back to message text matching. From 5b05fa1bb97f7aa25a390b45995b4fad0bd7ad79 Mon Sep 17 00:00:00 2001 From: Benjamin Michaelis Date: Mon, 1 Jun 2026 15:21:32 -0700 Subject: [PATCH 4/5] chore(chat): log stream update sequences on terminal failures Capture the recent streaming update type sequence in terminal stream failure logs (error, failed, incomplete) to improve diagnosis of opaque upstream stream errors. --- .../Services/AIChatService.cs | 32 +++++++++++++++---- 1 file changed, 25 insertions(+), 7 deletions(-) diff --git a/EssentialCSharp.Chat.Shared/Services/AIChatService.cs b/EssentialCSharp.Chat.Shared/Services/AIChatService.cs index f021865d..1ff9c61a 100644 --- a/EssentialCSharp.Chat.Shared/Services/AIChatService.cs +++ b/EssentialCSharp.Chat.Shared/Services/AIChatService.cs @@ -194,6 +194,7 @@ private static string SanitizeForXmlContext(string? input) => string? currentLegResponseId = null; var textPartsWithDelta = new HashSet(StringComparer.Ordinal); var refusalPartsWithDelta = new HashSet(StringComparer.Ordinal); + var streamUpdateTypes = new List(capacity: 64); #pragma warning disable OPENAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. List? pendingFunctionCalls = null; @@ -202,6 +203,9 @@ private static string SanitizeForXmlContext(string? input) => // separate helper that puts try/catch only around MoveNextAsync. await foreach (var update in RethrowContextLengthErrors(streamingUpdates, responseOptions.PreviousResponseId, cancellationToken)) { + var updateType = update.GetType().Name; + streamUpdateTypes.Add(updateType); + if (update is StreamingResponseCreatedUpdate created) { // Emit the response ID early so the controller can record ownership @@ -254,7 +258,8 @@ private static string SanitizeForXmlContext(string? input) => _Logger, currentLegResponseId, errorUpdate.Code ?? "unknown", - errorUpdate.Message ?? "no message provided"); + errorUpdate.Message ?? "no message provided", + BuildRecentUpdateSequence(streamUpdateTypes)); throw new ChatBackendUnavailableException( $"Streaming response error: {errorUpdate.Code ?? "unknown"} - {errorUpdate.Message ?? "no message provided"}", errorCode: "stream_response_error"); @@ -268,7 +273,8 @@ private static string SanitizeForXmlContext(string? input) => failedUpdate.Response.Status?.ToString(), failedUpdate.Response.Error?.Code.ToString(), failedUpdate.Response.Error?.Message, - failedUpdate.Response.IncompleteStatusDetails?.Reason?.ToString()); + failedUpdate.Response.IncompleteStatusDetails?.Reason?.ToString(), + BuildRecentUpdateSequence(streamUpdateTypes)); throw new ChatBackendUnavailableException( BuildStreamingTerminalFailureMessage(failedUpdate.Response, "failed"), errorCode: "stream_response_failed"); @@ -282,7 +288,8 @@ private static string SanitizeForXmlContext(string? input) => incompleteUpdate.Response.Status?.ToString(), incompleteUpdate.Response.Error?.Code.ToString(), incompleteUpdate.Response.Error?.Message, - incompleteUpdate.Response.IncompleteStatusDetails?.Reason?.ToString()); + incompleteUpdate.Response.IncompleteStatusDetails?.Reason?.ToString(), + BuildRecentUpdateSequence(streamUpdateTypes)); throw new ChatBackendUnavailableException( BuildStreamingTerminalFailureMessage(incompleteUpdate.Response, "incomplete"), errorCode: "stream_response_incomplete"); @@ -676,6 +683,15 @@ private static string BuildStreamingTerminalFailureMessage(ResponseResult respon return $"Streaming response ended with status '{terminalStatus}'."; } + + private static string BuildRecentUpdateSequence(List streamUpdateTypes) + { + const int MaxUpdatesToInclude = 40; + int count = streamUpdateTypes.Count; + if (count <= MaxUpdatesToInclude) + return string.Join(" -> ", streamUpdateTypes); + return $"... -> {string.Join(" -> ", streamUpdateTypes.Skip(count - MaxUpdatesToInclude))}"; + } #pragma warning restore OPENAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. [LoggerMessage(Level = LogLevel.Information, Message = "AI tool call invoked: tool={ToolName} iteration={Iteration} user={EndUserId}")] @@ -698,16 +714,17 @@ private static string BuildStreamingTerminalFailureMessage(ResponseResult respon [LoggerMessage( Level = LogLevel.Warning, - Message = "Streaming response error update received: responseId={ResponseId} code={Code} message={Message}")] + Message = "Streaming response error update received: responseId={ResponseId} code={Code} message={Message} updateSequence={UpdateSequence}")] private static partial void LogStreamingResponseErrorUpdate( ILogger logger, string? responseId, string code, - string message); + string message, + string updateSequence); [LoggerMessage( Level = LogLevel.Warning, - Message = "Streaming response terminal update: updateType={UpdateType} responseId={ResponseId} status={Status} errorCode={ErrorCode} errorMessage={ErrorMessage} incompleteReason={IncompleteReason}")] + Message = "Streaming response terminal update: updateType={UpdateType} responseId={ResponseId} status={Status} errorCode={ErrorCode} errorMessage={ErrorMessage} incompleteReason={IncompleteReason} updateSequence={UpdateSequence}")] private static partial void LogStreamingResponseTerminalUpdate( ILogger logger, string updateType, @@ -715,7 +732,8 @@ private static partial void LogStreamingResponseTerminalUpdate( string? status, string? errorCode, string? errorMessage, - string? incompleteReason); + string? incompleteReason, + string updateSequence); /// /// Returns true when the API error indicates the conversation context window was exceeded. From 36d10bae68900bfcdac6b27d49438fdbd0f3dd61 Mon Sep 17 00:00:00 2001 From: Benjamin Michaelis Date: Tue, 2 Jun 2026 19:54:57 -0700 Subject: [PATCH 5/5] fix(chat): replace ex.Message with generic client-safe error in streaming responses Exception details are now only in server logs (already logged via LogChatStreamErrorBeforeResponseStarted and LogChatStreamErrorMidStream). The errorCode from ChatBackendUnavailableException is still forwarded as it is a controlled application-level value. --- EssentialCSharp.Web/Controllers/ChatController.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/EssentialCSharp.Web/Controllers/ChatController.cs b/EssentialCSharp.Web/Controllers/ChatController.cs index 50150385..9c3b6ad7 100644 --- a/EssentialCSharp.Web/Controllers/ChatController.cs +++ b/EssentialCSharp.Web/Controllers/ChatController.cs @@ -278,14 +278,14 @@ public async Task StreamMessage([FromBody] ChatMessageRequest request, Cancellat LogChatStreamErrorBeforeResponseStarted(_Logger, ex, User.Identity?.Name); Response.StatusCode = StatusCodes.Status503ServiceUnavailable; Response.ContentType = "application/json"; - await Response.WriteAsJsonAsync(new { error = ex.Message, errorCode = ex.ErrorCode }, cancellationToken); + await Response.WriteAsJsonAsync(new { error = "Chat service unavailable", errorCode = ex.ErrorCode }, cancellationToken); } catch (ChatBackendUnavailableException ex) { LogChatStreamErrorMidStream(_Logger, ex, User.Identity?.Name); try { - var eventData = JsonSerializer.Serialize(new { type = "error", message = ex.Message, errorCode = ex.ErrorCode }); + var eventData = JsonSerializer.Serialize(new { type = "error", message = "Chat service unavailable", errorCode = ex.ErrorCode }); await Response.WriteAsync($"data: {eventData}\n\n", CancellationToken.None); await Response.Body.FlushAsync(CancellationToken.None); }