Appearance
Error Handling
How the Wilo API handles errors — from expected business failures to unhandled exceptions.
Error Response Formats
The API produces three distinct error response formats depending on the execution path:
| Source | Format | HTTP Status |
|---|---|---|
| Business errors (Result pattern) | { "code": "Module.Error", "message": "..." } | 400, 401, 403, 404, 409 |
| Validation errors (FluentValidation) | RFC 9457 ProblemDetails with errors dict | 400 |
| Unhandled exceptions (IExceptionHandler) | RFC 9457 ProblemDetails with traceId | 500, 503, 504 |
Business errors intentionally keep their own compact shape — the frontend is adapted to switch on code for field-level feedback. Changing this shape would require a frontend migration.
The Result Pattern for Business Errors
Expected business failures (member not found, duplicate name, insufficient permissions) never throw. Handlers return Result<T>, and endpoints map the error to the appropriate HTTP status:
csharp
// Handler returns a structured error
if (existingRole is not null)
return Result.Failure(RoleErrors.DuplicateName);
// Endpoint maps it to HTTP
return result.Error.Code switch
{
RoleErrors.Codes.DuplicateName => TypedResults.Conflict(result.Error),
_ => TypedResults.Problem(statusCode: 500),
};The JSON body is always { "code": "Module.Error", "message": "Human-readable description" }.
Infrastructure panics (database unreachable, S3 timeout, network failure) are not expected errors — they throw and are caught by the IExceptionHandler chain.
Problem Details (RFC 9457)
RFC 9457 defines a standard JSON shape for HTTP error responses:
json
{
"type": "https://tools.ietf.org/html/rfc9457",
"title": "An unexpected error occurred.",
"status": 500,
"instance": "GET /api/members",
"traceId": "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01"
}The traceId field links the response to the distributed trace in Aspire Dashboard (dev) or the OTLP collector (staging/production). Every ProblemDetails response produced by IExceptionHandler includes both traceId and instance.
IExceptionHandler — Chain of Responsibility
ASP.NET Core .NET 8+ provides IExceptionHandler, which replaces the older UseExceptionHandler(app => app.Run(...)) pattern. Multiple handlers are registered and invoked in order until one returns true (handled) or all return false (unhandled, falls through to the default).
Registered Handlers (in order)
| Handler | Exception type | HTTP status | Log level |
|---|---|---|---|
CancellationExceptionHandler | OperationCanceledException | 499 (no body) | Debug |
DatabaseExceptionHandler | DbException | 503 Service Unavailable | Error |
TimeoutExceptionHandler | TimeoutException | 504 Gateway Timeout | Warning |
GlobalExceptionHandler | Any (catch-all) | 500 Internal Server Error | Error |
The chain is registered in ExceptionHandlerExtensions.AddWiloExceptionHandlers() and activated in the pipeline by app.UseExceptionHandler() (inside UseWiloPipeline()).
Adding a New Handler
- Create
src/Wilo.Api/ExceptionHandlers/{Name}ExceptionHandler.csimplementingIExceptionHandler. - Return
falsefor exceptions the handler does not own — this passes control to the next handler. - Register it in
ExceptionHandlerExtensions.csbeforeGlobalExceptionHandler(catch-all must remain last). - Add unit tests in
tests/Wilo.Api.UnitTests/ExceptionHandlers/.
csharp
internal sealed class MyExceptionHandler(
ILogger<MyExceptionHandler> logger,
IProblemDetailsService problemDetailsService) : IExceptionHandler
{
public async ValueTask<bool> TryHandleAsync(
HttpContext context,
Exception exception,
CancellationToken cancellationToken)
{
if (exception is not MySpecificException)
return false;
logger.LogWarning(exception, "My exception for {Method} {Path}",
context.Request.Method, context.Request.Path);
context.Response.StatusCode = StatusCodes.Status422UnprocessableEntity;
await problemDetailsService.TryWriteAsync(new ProblemDetailsContext
{
HttpContext = context,
ProblemDetails = new ProblemDetails
{
Status = StatusCodes.Status422UnprocessableEntity,
Title = "The request could not be processed.",
},
});
return true;
}
}How to Test Exception Handlers
Unit tests for IExceptionHandler use DefaultHttpContext with a MemoryStream response body and NSubstitute mocks for ILogger<T> and IProblemDetailsService. The handler is instantiated directly via its primary constructor.
csharp
public sealed class MyExceptionHandlerTests
{
private static DefaultHttpContext CreateContext()
{
var context = new DefaultHttpContext();
context.Response.Body = new MemoryStream();
return context;
}
[Fact]
public async Task TryHandleAsync_MySpecificException_Returns422()
{
var logger = Substitute.For<ILogger<MyExceptionHandler>>();
var problemDetailsService = Substitute.For<IProblemDetailsService>();
var context = CreateContext();
var handler = new MyExceptionHandler(logger, problemDetailsService);
await handler.TryHandleAsync(context, new MySpecificException("test"), TestContext.Current.CancellationToken);
Assert.Equal(StatusCodes.Status422UnprocessableEntity, context.Response.StatusCode);
}
[Fact]
public async Task TryHandleAsync_OtherException_ReturnsFalse()
{
var context = CreateContext();
var handler = new MyExceptionHandler(
Substitute.For<ILogger<MyExceptionHandler>>(),
Substitute.For<IProblemDetailsService>());
var result = await handler.TryHandleAsync(context, new Exception("other"), TestContext.Current.CancellationToken);
Assert.False(result);
}
}Key points:
- Test naming follows
TryHandleAsync_{Scenario}_{ExpectedResult}. - Assert on
context.Response.StatusCodefor status code tests. - Assert on
problemDetailsService.Received(1).TryWriteAsync(Arg.Is<ProblemDetailsContext>(...))for response body tests. - Assert on
logger.Received(1).Log(LogLevel.{Level}, ...)for log level tests.
JwtRevocationMiddleware
When an authenticated request carries a JWT whose jti is in the Redis deny-list, JwtRevocationMiddleware short-circuits the pipeline and returns a structured 401:
json
{
"code": "Auth.TokenRevoked",
"message": "Your session has been revoked. Please log in again."
}Frontend Behavior
The Axios response interceptor in sites/Wilo.App/lib/api.ts checks for Auth.TokenRevoked before attempting a token refresh. When the code is present, the interceptor:
- Clears the in-memory access token.
- Shows a session-expired toast.
- Redirects to
/login.
This avoids the unnecessary refresh roundtrip that would occur if the 401 were treated as an expired token rather than an explicitly revoked one.