Skip to content

Exception Handling — RFC 7807 Problem Details

Granit.Http.ExceptionHandling provides a centralized exception-to-HTTP-response pipeline implementing RFC 7807 Problem Details. All exceptions are caught, mapped to status codes via a chain of responsibility, logged at the appropriate level, and serialized as application/problem+json.

[DependsOn(typeof(GranitHttpExceptionHandlingModule))]
public class AppModule : GranitModule { }

In Program.cs (must be the first middleware):

app.UseGranitExceptionHandling(); // Before routing, authentication, authorization
app.UseRouting();
app.UseAuthentication();
app.UseAuthorization();
flowchart TD
    A[Exception thrown] --> B{OperationCanceledException?}
    B -->|yes| C[Log Info, return true]
    B -->|no| D[IExceptionStatusCodeMapper chain]
    D --> E{5xx?}
    E -->|yes| F[Log Error]
    E -->|no| G{499?}
    G -->|yes| H[Log Info]
    G -->|no| I[Log Warning]
    F --> J[Build ProblemDetails]
    H --> J
    I --> J
    J --> K{IUserFriendlyException?}
    K -->|yes| L[Title = exception.Message]
    K -->|no| M{ExposeInternalErrorDetails?}
    M -->|yes| N[Title = raw message<br/>Detail = stack trace 5xx only]
    M -->|no| O[Title = per-status fallback<br/>Detail = null]
    L --> P[Write RFC 7807 response]
    N --> P
    O --> P

Before Granit 0.31, only 5xx messages were masked. Internal teams found themselves writing throw new ArgumentException($"User {userId} of tenant {tenantId} already has permission X") — those strings reached external callers as title. From 0.31, the mask applies to both 4xx and 5xx non-IUserFriendlyException cases — opt into exposing a 4xx message by implementing IUserFriendlyException on the exception type (or by carrying an IHasErrorCode so the localizer can pick up a translated title).

Per-status fallback titles when ExposeInternalErrorDetails = false and the exception is not user-friendly:

StatusTitle
5xx"An unexpected error occurred."
404"Resource not found."
403"Forbidden."
409"Conflict."
422"Validation failed."
other 4xx"Invalid request."

When an exception implements IHasValidationErrors, the field map is passed through ValidationErrorsSanitizer before being attached as problemDetails.Extensions["errors"]. The sanitizer strips raw operand values that frameworks like FluentValidation embed in error message templates (e.g. "must be greater than {ComparisonValue}" with an internal cap value) so the response carries only the localizable error code per field.

Chain of responsibility pattern. Mappers are evaluated in registration order; the first non-null result wins. The DefaultExceptionStatusCodeMapper is registered last as a catch-all.

Default mappings:

ExceptionStatus code
EntityNotFoundException404
NotFoundException404
ForbiddenException403
UnauthorizedAccessException403
ValidationException422
IHasValidationErrors422
ConflictException409
BusinessRuleViolationException422
BusinessException400
IHasErrorCode400
NotImplementedException501
OperationCanceledException499
TimeoutException408
(any other)500

Other Granit modules register their own mappers to extend the chain:

internal sealed class InvoiceConcurrencyMapper : IExceptionStatusCodeMapper
{
public int? TryGetStatusCode(Exception exception) => exception switch
{
InvoiceLockedException => StatusCodes.Status423Locked,
_ => null
};
}
// In module ConfigureServices:
services.AddSingleton<IExceptionStatusCodeMapper, InvoiceConcurrencyMapper>();

The response body includes standard RFC 7807 fields plus Granit-specific extensions:

{
"status": 422,
"title": "Invoice amount must be positive.",
"detail": null,
"traceId": "abcd1234ef567890",
"errorCode": "Invoices:InvalidAmount",
"errors": {
"Amount": ["Granit:Validation:GreaterThanValidator"]
}
}
ExtensionSourcePresent when
traceIdActivity.Current?.TraceId or HttpContext.TraceIdentifierAlways
errorCodeIHasErrorCode.ErrorCodeException implements IHasErrorCode
errorsIHasValidationErrors.ValidationErrorsException implements IHasValidationErrors
CategoryKey typesPackage
ModuleGranitHttpExceptionHandlingModule
PipelineIExceptionStatusCodeMapper, GranitExceptionHandlerGranit.Http.ExceptionHandling
OptionsExceptionHandlingOptionsGranit.Http.ExceptionHandling
ExtensionsAddGranitExceptionHandling(), UseGranitExceptionHandling()Granit.Http.ExceptionHandling