Skip to content

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:

SourceFormatHTTP Status
Business errors (Result pattern){ "code": "Module.Error", "message": "..." }400, 401, 403, 404, 409
Validation errors (FluentValidation)RFC 9457 ProblemDetails with errors dict400
Unhandled exceptions (IExceptionHandler)RFC 9457 ProblemDetails with traceId500, 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)

HandlerException typeHTTP statusLog level
CancellationExceptionHandlerOperationCanceledException499 (no body)Debug
DatabaseExceptionHandlerDbException503 Service UnavailableError
TimeoutExceptionHandlerTimeoutException504 Gateway TimeoutWarning
GlobalExceptionHandlerAny (catch-all)500 Internal Server ErrorError

The chain is registered in ExceptionHandlerExtensions.AddWiloExceptionHandlers() and activated in the pipeline by app.UseExceptionHandler() (inside UseWiloPipeline()).

Adding a New Handler

  1. Create src/Wilo.Api/ExceptionHandlers/{Name}ExceptionHandler.cs implementing IExceptionHandler.
  2. Return false for exceptions the handler does not own — this passes control to the next handler.
  3. Register it in ExceptionHandlerExtensions.cs before GlobalExceptionHandler (catch-all must remain last).
  4. 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.StatusCode for 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:

  1. Clears the in-memory access token.
  2. Shows a session-expired toast.
  3. 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.