Skip to content
96 changes: 68 additions & 28 deletions EssentialCSharp.Web/Controllers/ChatController.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using System.IO;
using System.Security.Claims;
using System.Text.Json;
using EssentialCSharp.Chat.Common.Services;
Expand All @@ -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<ChatController> _Logger;

public ChatController(ILogger<ChatController> logger, AIChatService aiChatService, ResponseIdValidationService responseIdValidationService)
{
_AiChatService = aiChatService;
_AIChatService = aiChatService;
_ResponseIdValidationService = responseIdValidationService;
_Logger = logger;
}
Expand All @@ -46,7 +47,7 @@ public async Task<IActionResult> 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,
Expand Down Expand Up @@ -75,15 +76,15 @@ 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;
}

var userId = User.FindFirstValue(ClaimTypes.NameIdentifier);
if (string.IsNullOrEmpty(userId))
{
Response.StatusCode = 401;
await Response.WriteAsJsonAsync(new { error = "Unauthorized." }, CancellationToken.None);
await Response.WriteAsJsonAsync(new { error = "Unauthorized." }, cancellationToken);
return;
}

Expand All @@ -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;
}

Expand All @@ -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,
Expand Down Expand Up @@ -137,49 +138,88 @@ public async Task StreamMessage([FromBody] ChatMessageRequest request, Cancellat
{
LogChatStreamCancelled(_Logger, User.Identity?.Name);
}
Comment thread
BenjaminMichaelis marked this conversation as resolved.
Outdated
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.
}
Comment thread
github-code-quality[bot] marked this conversation as resolved.
Fixed
catch (IOException) when (HttpContext.RequestAborted.IsCancellationRequested)
{
// Expected client disconnect while attempting a best-effort error response write.
}
Comment thread
github-code-quality[bot] marked this conversation as resolved.
Fixed
catch (ObjectDisposedException) when (HttpContext.RequestAborted.IsCancellationRequested)
{
// Response stream can already be disposed after an abrupt client disconnect.
}
Comment thread
github-code-quality[bot] marked this conversation as resolved.
Fixed
}
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.
}
Comment thread
github-code-quality[bot] marked this conversation as resolved.
Fixed
catch (IOException) when (HttpContext.RequestAborted.IsCancellationRequested)
{
// Expected client disconnect while attempting a best-effort error response write.
}
Comment thread
github-code-quality[bot] marked this conversation as resolved.
Fixed
catch (ObjectDisposedException) when (HttpContext.RequestAborted.IsCancellationRequested)
{
// Response stream can already be disposed after an abrupt client disconnect.
}
Comment thread
github-code-quality[bot] marked this conversation as resolved.
Fixed
}
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.
}
}
}
Expand Down
Loading