diff --git a/EssentialCSharp.Web/Controllers/ChatController.cs b/EssentialCSharp.Web/Controllers/ChatController.cs index 0fcd8577..754a7b56 100644 --- a/EssentialCSharp.Web/Controllers/ChatController.cs +++ b/EssentialCSharp.Web/Controllers/ChatController.cs @@ -1,3 +1,4 @@ +using System.IO; using System.Security.Claims; using System.Text.Json; using EssentialCSharp.Chat.Common.Services; @@ -15,13 +16,13 @@ namespace EssentialCSharp.Web.Controllers; [IgnoreAntiforgeryToken] public partial class ChatController : ControllerBase { - private readonly AIChatService _AiChatService; + private readonly AIChatService _AIChatService; private readonly ResponseIdValidationService _ResponseIdValidationService; private readonly ILogger _Logger; public ChatController(ILogger logger, AIChatService aiChatService, ResponseIdValidationService responseIdValidationService) { - _AiChatService = aiChatService; + _AIChatService = aiChatService; _ResponseIdValidationService = responseIdValidationService; _Logger = logger; } @@ -46,7 +47,7 @@ public async Task SendMessage([FromBody] ChatMessageRequest reque try { - var (response, responseId) = await _AiChatService.GetChatCompletion( + var (response, responseId) = await _AIChatService.GetChatCompletion( prompt: request.Message, previousResponseId: previousResponseId, enableContextualSearch: request.EnableContextualSearch, @@ -75,7 +76,7 @@ public async Task StreamMessage([FromBody] ChatMessageRequest request, Cancellat if (string.IsNullOrEmpty(request.Message)) { Response.StatusCode = 400; - await Response.WriteAsJsonAsync(new { error = "Message cannot be empty." }, CancellationToken.None); + await Response.WriteAsJsonAsync(new { error = "Message cannot be empty." }, cancellationToken); return; } @@ -83,7 +84,7 @@ public async Task StreamMessage([FromBody] ChatMessageRequest request, Cancellat if (string.IsNullOrEmpty(userId)) { Response.StatusCode = 401; - await Response.WriteAsJsonAsync(new { error = "Unauthorized." }, CancellationToken.None); + await Response.WriteAsJsonAsync(new { error = "Unauthorized." }, cancellationToken); return; } @@ -94,7 +95,7 @@ public async Task StreamMessage([FromBody] ChatMessageRequest request, Cancellat if (!_ResponseIdValidationService.ValidateResponseId(userId, previousResponseId)) { Response.StatusCode = 400; - await Response.WriteAsJsonAsync(new { error = "Invalid conversation context." }, CancellationToken.None); + await Response.WriteAsJsonAsync(new { error = "Invalid conversation context." }, cancellationToken); return; } @@ -104,7 +105,7 @@ public async Task StreamMessage([FromBody] ChatMessageRequest request, Cancellat try { - await foreach (var (text, responseId) in _AiChatService.GetChatCompletionStream( + await foreach (var (text, responseId) in _AIChatService.GetChatCompletionStream( prompt: request.Message, previousResponseId: previousResponseId, enableContextualSearch: request.EnableContextualSearch, @@ -133,53 +134,92 @@ public async Task StreamMessage([FromBody] ChatMessageRequest request, Cancellat await Response.WriteAsync("data: [DONE]\n\n", cancellationToken); await Response.Body.FlushAsync(cancellationToken); } - catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested || HttpContext.RequestAborted.IsCancellationRequested) + catch (OperationCanceledException) when (Response.HasStarted || cancellationToken.IsCancellationRequested || HttpContext.RequestAborted.IsCancellationRequested) { LogChatStreamCancelled(_Logger, User.Identity?.Name); } - catch (ConversationContextLimitExceededException) when (!Response.HasStarted) - { - Response.StatusCode = 400; - Response.ContentType = "application/json"; - await Response.WriteAsJsonAsync(new { error = "This conversation has grown too long. Please start a new one.", errorCode = "context_limit_exceeded" }, CancellationToken.None); - } catch (ConversationContextLimitExceededException ex) { - LogChatStreamErrorMidStream(_Logger, ex, User.Identity?.Name); - try + if (!Response.HasStarted) { - await Response.WriteAsync("data: {\"type\":\"error\",\"message\":\"This conversation has grown too long. Please start a new one.\",\"errorCode\":\"context_limit_exceeded\"}\n\n", CancellationToken.None); - await Response.Body.FlushAsync(CancellationToken.None); + if (cancellationToken.IsCancellationRequested || HttpContext.RequestAborted.IsCancellationRequested) + return; + + Response.StatusCode = 400; + Response.ContentType = "application/json"; + try + { + await Response.WriteAsJsonAsync(new { error = "This conversation has grown too long. Please start a new one.", errorCode = "context_limit_exceeded" }, cancellationToken); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested || HttpContext.RequestAborted.IsCancellationRequested) + { + // Best-effort write during an aborted request — no response body can be delivered. + } + catch (IOException) when (HttpContext.RequestAborted.IsCancellationRequested) + { + // Expected client disconnect while attempting a best-effort error response write. + } + catch (ObjectDisposedException) when (HttpContext.RequestAborted.IsCancellationRequested) + { + // Response stream can already be disposed after an abrupt client disconnect. + } } - catch (Exception) + else { - // Best-effort write to an already-streaming response. Kestrel can throw - // IOException (connection reset), OperationCanceledException, or - // ObjectDisposedException on abrupt client disconnect — swallow all to - // avoid masking the original exception. + LogChatStreamErrorMidStream(_Logger, ex, User.Identity?.Name); + try + { + await Response.WriteAsync("data: {\"type\":\"error\",\"message\":\"This conversation has grown too long. Please start a new one.\",\"errorCode\":\"context_limit_exceeded\"}\n\n", cancellationToken); + await Response.Body.FlushAsync(cancellationToken); + } + catch (Exception writeException) when (writeException is IOException or OperationCanceledException or ObjectDisposedException) + { + // Best-effort write to an already-streaming response. Kestrel can throw + // IOException (connection reset), OperationCanceledException, or + // ObjectDisposedException on abrupt client disconnect — swallow expected + // transport/disconnect exceptions to avoid masking the original exception. + } } } catch (Exception ex) when (!Response.HasStarted) { LogChatStreamErrorBeforeResponseStarted(_Logger, ex, User.Identity?.Name); + if (cancellationToken.IsCancellationRequested || HttpContext.RequestAborted.IsCancellationRequested) + return; + Response.StatusCode = 500; Response.ContentType = "application/json"; - await Response.WriteAsJsonAsync(new { error = "Chat service unavailable" }, CancellationToken.None); + try + { + await Response.WriteAsJsonAsync(new { error = "Chat service unavailable" }, cancellationToken); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested || HttpContext.RequestAborted.IsCancellationRequested) + { + // Best-effort write during an aborted request — no response body can be delivered. + } + catch (IOException) when (HttpContext.RequestAborted.IsCancellationRequested) + { + // Expected client disconnect while attempting a best-effort error response write. + } + catch (ObjectDisposedException) when (HttpContext.RequestAborted.IsCancellationRequested) + { + // Response stream can already be disposed after an abrupt client disconnect. + } } catch (Exception ex) { LogChatStreamErrorMidStream(_Logger, ex, User.Identity?.Name); try { - await Response.WriteAsync("data: {\"type\":\"error\",\"message\":\"Stream interrupted\"}\n\n", CancellationToken.None); - await Response.Body.FlushAsync(CancellationToken.None); + await Response.WriteAsync("data: {\"type\":\"error\",\"message\":\"Stream interrupted\"}\n\n", cancellationToken); + await Response.Body.FlushAsync(cancellationToken); } - catch (Exception) + catch (Exception writeException) when (writeException is IOException or OperationCanceledException or ObjectDisposedException) { // Best-effort write to an already-streaming response. Kestrel can throw // IOException (connection reset), OperationCanceledException, or - // ObjectDisposedException on abrupt client disconnect — swallow all to - // avoid masking the original exception. + // ObjectDisposedException on abrupt client disconnect — swallow expected + // transport/disconnect exceptions to avoid masking the original exception. } } }