Exception Handling
Errors travel between service and client as typed exceptions. This guide covers throwing them on the service, mapping unexpected exceptions with a handler, and catching them on the client.
The exception hierarchy
ApiException carries an HTTP status code, a message intended for the user, and an optional ErrorCode for clients that need to react to specific conditions programmatically. Derived types exist for the statuses the conventions use:
| Exception | Status | Typical use |
|---|---|---|
| BadRequestApiException | 400 | Malformed request. |
| UnauthorizedApiException | 401 | Not signed in, or credentials rejected. |
| ForbiddenApiException | 403 | Signed in but not permitted, or blocked cross-origin request. |
| NotFoundApiException | 404 | The resource does not exist (or is hidden from the user). |
| UserChangedApiException | 412 | The user ID precondition does not match the session user. |
| ValidationApiException | 422 | Input failed validation. |
| UserRequiredApiException | 428 | The user ID precondition is missing. |
| ServerErrorApiException | 500 | Unexpected failure, reported with a reference ID. |
Any other status is reported as the base ApiException with the status code set. The wire format is described in Error Response Format.
Throwing Errors on the Service
Throw the exception that matches the condition. The message is sent to the client verbatim, so write it for the user:
if (!await HasPermissionAsync(token.UserId, document))
throw new ForbiddenApiException("You do not have permission to edit this document.");
Set ErrorCode when a client needs to distinguish a case beyond the status code, such as offering a specific recovery action:
throw new UnauthorizedApiException("Please confirm your email address before signing in.") {
ErrorCode = "email-confirmation-required",
};
Error codes consist of ASCII letters, digits, hyphens and underscores. Define them as constants in a data contracts assembly so both sides refer to the same strings.
ApiExceptionMiddleware, installed by UseApiResponseHandling, catches exceptions from endpoints and writes the error response. Exceptions thrown after a streaming response has started are reported inside the stream instead; see Streaming Responses.
Mapping Exceptions with a Handler
An IApiExceptionHandler decides how every exception is reported. It is consulted by the middleware, by streaming responses, and by the SignalR hub filter, so one implementation gives consistent behavior everywhere. With no handler registered, ApiException instances are reported as-is and anything else propagates through the pipeline.
ApiExceptionHandler is the recommended base. Its default behavior:
- ApiException instances are reported unchanged.
- MapAsync is called for other exceptions so an application can map its own types.
- Anything still unmapped is treated as unexpected: a reference ID is generated, the exception is logged through ILogger with the request method, path and reference ID as structured properties, and a ServerErrorApiException whose message includes the reference ID is reported. The original exception is attached as the inner exception.
- In the Development environment unexpected exceptions propagate instead, so the developer exception page can display them.
Register the default behavior with AddApiExceptionHandler, or derive from the base class to add mappings:
public sealed class AppExceptionHandler(ILogger<AppExceptionHandler> logger, IHostEnvironment environment)
: ApiExceptionHandler(logger, environment)
{
protected override ValueTask<ApiException?> MapAsync(HttpContext httpContext, Exception exception)
{
return new(exception is ValidationException ex ? new ValidationApiException(string.Join("\n", ex.Errors)) : null);
}
protected override string CreateServerErrorMessage(Guid referenceId) =>
$"Something went wrong on our end. Please contact support and quote reference {referenceId}.";
}
services.AddApiExceptionHandler<AppExceptionHandler>();
HandleUnexpectedAsync, LogUnexpected and PropagateUnexpectedExceptions are also virtual for deeper customization. Implement IApiExceptionHandler directly only if none of the base behavior applies.
Tip
Because the handler logs through ILogger, unexpected exceptions land in the same place as framework logging. A file sink such as Serilog with a rolling file and structured properties makes the reference ID greppable, and the client shows the same ID to the user.
Catching Errors on the Client
SendAsync throws the exception type that matches the response status, with the message and error code from the response. Catch the types the calling code can act on and let the rest propagate to a general handler:
try
{
await client.SignInAsync(command);
}
catch (UnauthorizedApiException ex) when (ex.ErrorCode == ErrorCodes.EmailConfirmationRequired)
{
OfferToResendConfirmation();
}
catch (UnauthorizedApiException ex)
{
ShowError(ex.Message);
}
catch (ServerErrorApiException ex)
{
ShowError(ex.Message); // includes the reference ID
}
Two exceptions deserve special handling in most clients:
- UnauthorizedApiException on a request that previously worked means the session ended (expired, signed out elsewhere, or invalidated). The client's stored session token has already been cleared by the time the exception is thrown, so navigating to sign-in is usually all that is needed.
- UserChangedApiException and UserRequiredApiException indicate the client's notion of the current user is stale. Reload the current session and rebuild any user-scoped state.
Error responses that are not in the toolkit's format (for example a proxy error page) surface as the base ApiException with the raw body available through ErrorContent.
Next Steps
Continue with these related articles:
- Error Response Format - The wire format for regular, streaming and hub errors.
- Streaming Responses - How errors are reported after a stream has started.
- SignalR Hubs - Reporting hub exceptions with the same types.