Error Handling
Complete guide to error handling and HTTP status mapping in Pragmatic.Endpoints.
Result Pattern
Section titled “Result Pattern”Pragmatic.Endpoints uses the Result pattern from Pragmatic.Result for type-safe error handling:
[Endpoint(HttpVerb.Get, "/users/{id}")]public partial class GetUserEndpoint : Endpoint<UserDto>{ private IUserRepository _users = null!;
[FromRoute] public Guid Id { get; set; }
public override async Task<Result<UserDto>> HandleAsync(CancellationToken ct) { var user = await _users.GetByIdAsync(Id, ct); if (user is null) return new NotFoundError("User", Id); // Error case
return new UserDto(user); // Success case }}Standard Error Types
Section titled “Standard Error Types”Pragmatic.Result.Http provides standard error types with automatic HTTP status mapping:
| Error Type | HTTP Status | Use Case |
|---|---|---|
NotFoundError | 404 | Resource not found |
ValidationError | 422 | Invalid input |
BadRequestError | 400 | Malformed request |
UnauthorizedError | 401 | Authentication required |
ForbiddenError | 403 | Insufficient permissions |
ConflictError | 409 | Duplicate/concurrency conflict |
InternalServerError | 500 | Unexpected failure |
ServiceUnavailableError | 503 | External service down |
Using Standard Errors
Section titled “Using Standard Errors”[Endpoint(HttpVerb.Post, "/orders")]public partial class CreateOrderEndpoint : Endpoint<OrderDto>{ public override async Task<Result<OrderDto>> HandleAsync(CancellationToken ct) { // Validation error if (Items.Count == 0) return new ValidationError("Items", "At least one item required");
// Not found var customer = await _customers.GetAsync(CustomerId, ct); if (customer is null) return NotFoundError.Create("Customer", CustomerId);
// Conflict (duplicate) if (await _orders.ExistsAsync(IdempotencyKey, ct)) return new ConflictError("Order with this idempotency key exists");
// Success var order = new Order(...); return new OrderDto(order); }}Multiple Error Types
Section titled “Multiple Error Types”DomainAction endpoints can declare multiple error types:
[Endpoint(HttpVerb.Put, "/users/{id}")]public partial class UpdateUser : DomainAction<UserDto, NotFoundError, ValidationError, ForbiddenError>{ public override async Task<Result<UserDto, IError>> Execute(CancellationToken ct) { var user = await Users.GetByIdAsync(Id, ct); if (user is null) return new NotFoundError("User", Id); // → 404
if (!CurrentUser.CanEdit(user)) return new ForbiddenError("Cannot edit this user"); // → 403
var validation = user.Validate(Name, Email); if (validation.HasErrors) return new ValidationError(validation.Errors); // → 422
return new UserDto(user); // → 200 }}The generator produces OpenAPI documentation for all error types:
responses: 200: description: Success content: application/json: schema: $ref: '#/components/schemas/UserDto' 404: description: Not Found content: application/problem+json: schema: $ref: '#/components/schemas/ProblemDetails' 403: description: Forbidden 422: description: Validation FailedCustom Error Types
Section titled “Custom Error Types”Create domain-specific errors:
public sealed class InsufficientStockError : IError{ public string Code => "INSUFFICIENT_STOCK"; public string Message { get; } public int StatusCode => 422;
public Guid ProductId { get; } public int Requested { get; } public int Available { get; }
public InsufficientStockError(Guid productId, int requested, int available) { ProductId = productId; Requested = requested; Available = available; Message = $"Insufficient stock for product {productId}: requested {requested}, available {available}"; }}
[Endpoint(HttpVerb.Post, "/orders")]public partial class CreateOrder : DomainAction<OrderId, InsufficientStockError, ValidationError>{ public override async Task<Result<OrderId, IError>> Execute(CancellationToken ct) { foreach (var item in Items) { var stock = await Inventory.GetStockAsync(item.ProductId, ct); if (stock < item.Quantity) return new InsufficientStockError(item.ProductId, item.Quantity, stock); } // ... }}ProblemDetails Response
Section titled “ProblemDetails Response”Errors are automatically converted to RFC 7807 ProblemDetails:
{ "type": "https://httpstatuses.io/404", "title": "Not Found", "status": 404, "detail": "User with ID '3fa85f64-5717-4562-b3fc-2c963f66afa6' was not found.", "instance": "/users/3fa85f64-5717-4562-b3fc-2c963f66afa6", "extensions": { "code": "ENTITY_NOT_FOUND", "resourceType": "User", "resourceId": "3fa85f64-5717-4562-b3fc-2c963f66afa6" }}Customizing Error Response
Section titled “Customizing Error Response”// Implement IHttpError for custom ProblemDetailspublic sealed class RateLimitExceededError : IError, IHttpError{ public string Code => "RATE_LIMIT_EXCEEDED"; public string Message => "Too many requests"; public int StatusCode => 429;
public int RetryAfterSeconds { get; }
public RateLimitExceededError(int retryAfterSeconds) { RetryAfterSeconds = retryAfterSeconds; }
// Custom headers public void ConfigureResponse(HttpResponse response) { response.Headers.RetryAfter = RetryAfterSeconds.ToString(); }
// Custom problem details public ProblemDetails ToProblemDetails() { return new ProblemDetails { Type = "https://api.example.com/errors/rate-limit", Title = "Rate Limit Exceeded", Status = 429, Detail = $"Please retry after {RetryAfterSeconds} seconds", Extensions = { ["retryAfter"] = RetryAfterSeconds } }; }}Void Endpoints
Section titled “Void Endpoints”For operations without a response body:
[Endpoint(HttpVerb.Delete, "/users/{id}")][HttpStatus(204)]public partial class DeleteUserEndpoint : VoidEndpoint{ [FromRoute] public Guid Id { get; set; }
public override async Task<VoidResult> HandleAsync(CancellationToken ct) { var user = await _users.GetByIdAsync(Id, ct); if (user is null) return new NotFoundError("User", Id); // → 404
await _users.DeleteAsync(Id, ct); return Success; // → 204 No Content }}Error Mapping
Section titled “Error Mapping”The generator creates error mapping logic:
// Generated handlervar result = await endpoint.HandleAsync(ct);
return result.Match( (success) => Results.Ok(success), (error) => MapError(error));
static IResult MapError(IError error){ return error.ToResult(); // Uses ErrorExtensions}ErrorExtensions
Section titled “ErrorExtensions”public static class ErrorExtensions{ public static IResult ToResult(this IError error) { var statusCode = error.StatusCode; var problemDetails = error.ToProblemDetails();
return Results.Problem(problemDetails, statusCode: statusCode); }}Exception Handling
Section titled “Exception Handling”For unexpected exceptions, use ASP.NET Core’s exception handling:
app.UseExceptionHandler(error =>{ error.Run(async context => { context.Response.StatusCode = 500; context.Response.ContentType = "application/problem+json";
var problem = new ProblemDetails { Type = "https://httpstatuses.io/500", Title = "Internal Server Error", Status = 500, Detail = "An unexpected error occurred" };
await context.Response.WriteAsJsonAsync(problem); });});Validation Errors
Section titled “Validation Errors”Integration with Pragmatic.Validation:
[Endpoint(HttpVerb.Post, "/users")]public partial class CreateUser : DomainAction<UserId, ValidationError>{ [Required] [Email] public required string Email { get; set; }
[Required] [MinLength(2)] public required string Name { get; set; }
// Validation runs automatically before Execute public override async Task<Result<UserId, IError>> Execute(CancellationToken ct) { // If we get here, validation passed var user = new User(Email, Name); await Users.CreateAsync(user, ct); return user.Id; }}Validation errors return 422 with details:
{ "type": "https://httpstatuses.io/422", "title": "Validation Failed", "status": 422, "errors": { "Email": ["Invalid email format"], "Name": ["Name must be at least 2 characters"] }}Best Practices
Section titled “Best Practices”- Use typed errors - Not generic exceptions
- Include context - Resource type, ID, reason
- Map to correct status - Follow HTTP semantics
- Localize messages - Use Pragmatic.Internationalization
- Log errors - But don’t expose internals to clients
- Document all error types - In DomainAction generics