diff --git a/CHANGELOG.md b/CHANGELOG.md index 50a1f36..196e4ca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,10 @@ # Changelog +## [3.3.0] - 2026-10-05 +### Changes +- A `ServerFailureResult` (500) now sends its `UserMessage` (new in Linn.Common.Facade 13.6.0, which this + version needs) in the body - text written to be shown, e.g. "The change was saved, but ... failed", + which tells a caller not to simply retry. Its `Message` (diagnostic detail) is never sent, so existing + `ServerFailureResult`s - which have no `UserMessage` - are still an empty 500. ## [3.2.0] - 2026-08-20 ### Changes - StreamCopyingResultHandler now handles ForbiddenResult (403). Previously a forbidden result fell through to the default case and returned 500. diff --git a/src/Handlers/ResultResponseWriter.cs b/src/Handlers/ResultResponseWriter.cs index 08a5edb..f249726 100644 --- a/src/Handlers/ResultResponseWriter.cs +++ b/src/Handlers/ResultResponseWriter.cs @@ -77,8 +77,12 @@ await res.WriteAsync( cancellationToken); break; - case ServerFailureResult _: + case ServerFailureResult r: res.StatusCode = 500; + + // only the UserMessage, written to be shown (e.g. Linn.Common.Facade's "The change was + // saved, but ... failed"); Message is diagnostic detail and is never sent + await res.WriteAsync(this.SerializeOptional(NullIfEmpty(r.UserMessage), null), cancellationToken); break; default: @@ -87,6 +91,8 @@ await res.WriteAsync( } } + private static string? NullIfEmpty(string? message) => string.IsNullOrEmpty(message) ? null : message; + private string SerializeOptional(object? a, object? b) { if (a != null) diff --git a/src/Linn.Common.Service.csproj b/src/Linn.Common.Service.csproj index 90411ea..1fa46f0 100644 --- a/src/Linn.Common.Service.csproj +++ b/src/Linn.Common.Service.csproj @@ -5,7 +5,7 @@ enable Linn.Common.Service Linn.Common.Service - 3.2.0 + 3.3.0 enable @@ -13,7 +13,7 @@ - + diff --git a/tests/Linn.Common.Service.Tests.csproj b/tests/Linn.Common.Service.Tests.csproj index fd2e32b..ebf03d8 100644 --- a/tests/Linn.Common.Service.Tests.csproj +++ b/tests/Linn.Common.Service.Tests.csproj @@ -12,7 +12,7 @@ - + diff --git a/tests/WhenWritingAServerFailure.cs b/tests/WhenWritingAServerFailure.cs new file mode 100644 index 0000000..35f4df6 --- /dev/null +++ b/tests/WhenWritingAServerFailure.cs @@ -0,0 +1,62 @@ +namespace Linn.Common.Service.Tests +{ + using System.IO; + using System.Net; + using System.Threading; + using System.Threading.Tasks; + + using FluentAssertions; + + using Linn.Common.Facade; + using Linn.Common.Service.Handlers; + using Linn.Common.Service.Tests.Fake.Resources; + + using Microsoft.AspNetCore.Http; + + using NUnit.Framework; + + public class WhenWritingAServerFailure + { + private JsonResultHandler handler; + + private DefaultHttpContext context; + + [SetUp] + public void SetUp() + { + this.handler = new JsonResultHandler(); + this.context = new DefaultHttpContext(); + this.context.Response.Body = new MemoryStream(); + } + + [Test] + public async Task ShouldSendTheUserMessageButNotTheDetail() + { + await this.Write(new ServerFailureResult( + "The change was saved, but writing its log failed (SqlException: deadlock on log_table)", + "The change was saved, but writing its log failed")); + + this.context.Response.StatusCode.Should().Be((int)HttpStatusCode.InternalServerError); + (await this.Body()).Should().Be("\"The change was saved, but writing its log failed\""); + } + + [Test] + public async Task ShouldSendNothingForAnExistingServerFailure() + { + // a message only - diagnostic, e.g. an upstream service's raw error - stays unsent + await this.Write(new ServerFailureResult("Unexpected status code 502: upstream stack trace")); + + this.context.Response.StatusCode.Should().Be((int)HttpStatusCode.InternalServerError); + (await this.Body()).Should().BeEmpty(); + } + + private Task Write(IResult result) => + this.handler.Handle(this.context.Request, this.context.Response, result, CancellationToken.None); + + private async Task Body() + { + this.context.Response.Body.Position = 0; + return await new StreamReader(this.context.Response.Body).ReadToEndAsync(); + } + } +}