ErrorHound is a lightweight, flexible error-handling middleware for ASP.NET Core applications. It provides consistent, standardized error responses across your entire API, with built-in support for common HTTP errors, field-level validation, and fully customizable response formatting.
⚠️ Version 2.0 Breaking Change If you're upgrading from v1.x, the default error response format has changed to use an envelope structure withsuccess,error, andmetafields. This provides consistency with SuccessHound responses. See the CHANGELOG for migration details.
- Features
- Installation
- Quick Start
- Setup Guide
- Built-in Errors
- Using All Error Types
- Response Formats
- Validation Errors
- Creating Custom Errors
- Real-World Scenarios
- API Reference
- Testing
- Best Practices
- Contributing
- License
- 11 Built-in API errors covering common HTTP status codes (400, 401, 403, 404, 409, 429, 500, 503, 504)
- Field-level validation errors with support for multiple errors per field
- Automatic logging of all exceptions with appropriate log levels
- Consistent JSON responses across your entire API
- Custom error formatters with dependency injection support for complete control over response format
- Type-safe configuration using the abstraction pattern instead of delegates
- Minimal setup - works with both Minimal APIs and classic ASP.NET Core
- Zero dependencies beyond ASP.NET Core
- Fully tested with comprehensive xUnit test coverage
Install ErrorHound via NuGet Package Manager:
dotnet add package ErrorHoundOr via Package Manager Console:
Install-Package ErrorHoundHere's the simplest way to get started with ErrorHound:
using ErrorHound.Extensions;
using ErrorHound.Formatters;
using ErrorHound.BuiltIn;
var builder = WebApplication.CreateBuilder(args);
// Register ErrorHound services
builder.Services.AddErrorHound(options =>
{
options.UseFormatter<DefaultErrorFormatter>();
});
var app = builder.Build();
// Add ErrorHound middleware (must be early in the pipeline)
app.UseErrorHound();
app.MapGet("/users/{id}", (int id) =>
{
if (id <= 0)
throw new BadRequestError("User ID must be greater than 0");
if (id > 1000)
throw new NotFoundError($"User with ID {id} not found");
return new { Id = id, Name = "John Doe" };
});
app.Run();Response when id = -1:
{
"success": false,
"error": {
"code": "BAD_REQUEST",
"message": "The request was invalid or malformed.",
"details": "User ID must be greater than 0"
},
"meta": {
"timestamp": "2025-12-28T18:03:28.6056493Z",
"version": "v1.0"
}
}For modern ASP.NET Core applications using Minimal APIs:
using ErrorHound.Extensions;
using ErrorHound.Formatters;
var builder = WebApplication.CreateBuilder(args);
// Step 1: Register ErrorHound services with a formatter
builder.Services.AddErrorHound(options =>
{
options.UseFormatter<DefaultErrorFormatter>();
});
var app = builder.Build();
// Step 2: Add ErrorHound middleware early in the pipeline
// Place it before routing, authentication, and other middleware
app.UseErrorHound();
app.MapGet("/", () => "Hello World");
app.Run();For traditional ASP.NET Core applications with controllers:
using ErrorHound.Extensions;
using ErrorHound.Formatters;
public class Startup
{
public void ConfigureServices(IServiceCollection services)
{
services.AddControllers();
// Register ErrorHound services with a formatter
services.AddErrorHound(options =>
{
options.UseFormatter<DefaultErrorFormatter>();
});
}
public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
// Add ErrorHound middleware as the first middleware
app.UseErrorHound();
app.UseRouting();
app.UseAuthentication();
app.UseAuthorization();
app.UseEndpoints(endpoints =>
{
endpoints.MapControllers();
});
}
}Important: Always place UseErrorHound() early in your middleware pipeline, before routing, authentication, and authorization.
ErrorHound provides 11 ready-to-use error types:
| Error Class | HTTP Status | Error Code | Default Message |
|---|---|---|---|
BadRequestError |
400 | BAD_REQUEST |
"The request was invalid or malformed." |
UnauthorizedError |
401 | UNAUTHORIZED |
"Authentication is required to access this resource." |
ForbiddenError |
403 | FORBIDDEN |
"You do not have permission to access this resource." |
NotFoundError |
404 | NOT_FOUND |
"The requested resource could not be found." |
ConflictError |
409 | CONFLICT |
"The request could not be completed due to a conflict." |
TooManyRequestsError |
429 | TOO_MANY_REQUESTS |
"Too many requests have been made. Please try again later." |
InternalServerError |
500 | INTERNAL_SERVER |
"An unexpected internal server error occurred." |
DatabaseError |
500 | DATABASE |
"A server error occurred while accessing the database." |
ServiceUnavailableError |
503 | SERVICE_UNAVAILABLE |
"The service is unavailable." |
TimeoutError |
504 | TIMEOUT |
"The request timed out while processing." |
ValidationError |
400 | VALIDATION |
"Validation failed" |
Here's how to use each built-in error type with practical examples:
Use when the client sends invalid or malformed data:
app.MapPost("/products", (CreateProductRequest request) =>
{
if (request.Price < 0)
throw new BadRequestError("Price cannot be negative");
if (string.IsNullOrWhiteSpace(request.Name))
throw new BadRequestError("Product name is required");
// Create product...
});Response:
{
"code": "BAD_REQUEST",
"message": "The request was invalid or malformed.",
"status": 400,
"details": "Price cannot be negative"
}Use when authentication is required or has failed:
app.MapGet("/admin/dashboard", (HttpContext context) =>
{
var token = context.Request.Headers["Authorization"].FirstOrDefault();
if (string.IsNullOrEmpty(token))
throw new UnauthorizedError("Authentication token is required");
if (!IsValidToken(token))
throw new UnauthorizedError("Invalid or expired token");
// Return dashboard data...
});Response:
{
"code": "UNAUTHORIZED",
"message": "Authentication is required to access this resource.",
"status": 401,
"details": "Invalid or expired token"
}Use when a user is authenticated but lacks permission:
app.MapDelete("/users/{id}", (int id, HttpContext context) =>
{
var currentUserId = GetCurrentUserId(context);
var currentUserRole = GetUserRole(context);
if (currentUserRole != "Admin" && currentUserId != id)
throw new ForbiddenError("You can only delete your own account");
// Delete user...
});Response:
{
"code": "FORBIDDEN",
"message": "You do not have permission to access this resource.",
"status": 403,
"details": "You can only delete your own account"
}Use when a requested resource doesn't exist:
app.MapGet("/orders/{id}", async (int id, IOrderRepository repo) =>
{
var order = await repo.GetByIdAsync(id);
if (order == null)
throw new NotFoundError($"Order with ID {id} not found");
return order;
});Response:
{
"code": "NOT_FOUND",
"message": "The requested resource could not be found.",
"status": 404,
"details": "Order with ID 123 not found"
}Use when a request conflicts with current state:
app.MapPost("/users/register", async (RegisterRequest request, IUserRepository repo) =>
{
var existingUser = await repo.FindByEmailAsync(request.Email);
if (existingUser != null)
throw new ConflictError($"User with email {request.Email} already exists");
// Create user...
});Response:
{
"code": "CONFLICT",
"message": "The request could not be completed due to a conflict.",
"status": 409,
"details": "User with email john@example.com already exists"
}Use for rate limiting:
app.MapPost("/api/send-email", async (EmailRequest request, IRateLimiter limiter) =>
{
var allowed = await limiter.CheckRateLimitAsync(request.UserId, "email", max: 10, window: TimeSpan.FromHours(1));
if (!allowed)
throw new TooManyRequestsError("Email rate limit exceeded. Maximum 10 emails per hour.");
// Send email...
});Response:
{
"code": "TOO_MANY_REQUESTS",
"message": "Too many requests have been made. Please try again later.",
"status": 429,
"details": "Email rate limit exceeded. Maximum 10 emails per hour."
}Use for unexpected server errors:
app.MapGet("/reports/generate", () =>
{
try
{
// Complex report generation...
return GenerateReport();
}
catch (Exception ex)
{
_logger.LogError(ex, "Report generation failed");
throw new InternalServerError("Failed to generate report. Please try again later.");
}
});Response:
{
"code": "INTERNAL_SERVER",
"message": "An unexpected internal server error occurred.",
"status": 500,
"details": "Failed to generate report. Please try again later."
}Use for database-specific errors:
app.MapPost("/orders", async (CreateOrderRequest request, AppDbContext db) =>
{
try
{
var order = new Order { /* ... */ };
db.Orders.Add(order);
await db.SaveChangesAsync();
return order;
}
catch (DbUpdateException ex)
{
_logger.LogError(ex, "Database error creating order");
throw new DatabaseError("Failed to create order due to a database error");
}
});Response:
{
"code": "DATABASE",
"message": "A server error occurred while accessing the database.",
"status": 500,
"details": "Failed to create order due to a database error"
}Use when an external service is unavailable:
app.MapGet("/payment/status/{id}", async (string id, IPaymentGateway gateway) =>
{
try
{
return await gateway.GetPaymentStatusAsync(id);
}
catch (HttpRequestException)
{
throw new ServiceUnavailableError("Payment gateway is temporarily unavailable. Please try again later.");
}
});Response:
{
"code": "SERVICE_UNAVAILABLE",
"message": "The service is unavailable.",
"status": 503,
"details": "Payment gateway is temporarily unavailable. Please try again later."
}Use when operations exceed time limits:
app.MapGet("/analytics/report", async (IAnalyticsService analytics) =>
{
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30));
try
{
return await analytics.GenerateReportAsync(cts.Token);
}
catch (OperationCanceledException)
{
throw new TimeoutError("Report generation timed out. Please try with a smaller date range.");
}
});Response:
{
"code": "TIMEOUT",
"message": "The request timed out while processing.",
"status": 504,
"details": "Report generation timed out. Please try with a smaller date range."
}ErrorHound v2.0+ uses a consistent envelope format that matches SuccessHound, providing a uniform structure for both success and error responses:
{
"success": false,
"error": {
"code": "ERROR_CODE",
"message": "Human-readable error message",
"details": "Optional additional details or null"
},
"meta": {
"timestamp": "2025-12-28T18:03:28.6056493Z",
"version": "v1.0"
}
}Example with NotFoundError:
throw new NotFoundError("User with ID 123 not found");{
"success": false,
"error": {
"code": "NOT_FOUND",
"message": "The requested resource could not be found.",
"details": "User with ID 123 not found"
},
"meta": {
"timestamp": "2025-12-28T18:03:28.6056493Z",
"version": "v1.0"
}
}Example with ValidationError:
var validation = new ValidationError();
validation.AddFieldError("Email", "Email is required");
validation.AddFieldError("Password", "Password must be at least 8 characters");
throw validation;{
"success": false,
"error": {
"code": "VALIDATION",
"message": "Validation failed",
"details": {
"Email": ["Email is required"],
"Password": ["Password must be at least 8 characters"]
}
},
"meta": {
"timestamp": "2025-12-28T18:03:28.6056493Z",
"version": "v1.0"
}
}Example without details:
throw new UnauthorizedError();{
"success": false,
"error": {
"code": "UNAUTHORIZED",
"message": "Authentication is required to access this resource.",
"details": null
},
"meta": {
"timestamp": "2025-12-28T18:03:28.6056493Z",
"version": "v1.0"
}
}You can completely customize the error response body format while preserving the original HTTP status codes by creating a custom formatter that implements IErrorResponseFormatter. This is useful for:
- Matching existing API response formats
- Adding additional metadata (timestamps, request IDs, etc.)
- Wrapping errors in a consistent envelope
Important: The HTTP status code (404, 400, 401, etc.) is always preserved from the original error. Only the JSON response body structure changes.
using ErrorHound.Abstractions;
using ErrorHound.Core;
public sealed class CustomErrorFormatter : IErrorResponseFormatter
{
public object Format(ApiError error) => new
{
success = false,
errorCode = error.Code,
errorMessage = error.Message,
timestamp = DateTime.UtcNow
};
}
// Register the custom formatter
builder.Services.AddErrorHound(options =>
{
options.UseFormatter<CustomErrorFormatter>();
});When you throw:
throw new NotFoundError("User not found");Response becomes:
HTTP/1.1 404 Not Found
Content-Type: application/json
{
"success": false,
"errorCode": "NOT_FOUND",
"errorMessage": "The requested resource could not be found.",
"timestamp": "2025-12-13T15:30:00Z"
}
Notice: The HTTP status is 404 Not Found (preserved from NotFoundError), but the response body uses your custom format!
public sealed class EnvelopeErrorFormatter : IErrorResponseFormatter
{
public object Format(ApiError error) => new
{
success = false,
error = new
{
code = error.Code,
message = error.Message,
details = error.Details
},
meta = new
{
timestamp = DateTime.UtcNow,
requestId = Guid.NewGuid().ToString(),
version = "v1"
}
};
}
// Register the formatter
builder.Services.AddErrorHound(options =>
{
options.UseFormatter<EnvelopeErrorFormatter>();
});Response:
{
"success": false,
"error": {
"code": "BAD_REQUEST",
"message": "The request was invalid or malformed.",
"details": "Invalid product ID"
},
"meta": {
"timestamp": "2025-12-13T15:30:00Z",
"requestId": "550e8400-e29b-41d4-a716-446655440000",
"version": "v1"
}
}public sealed class JSendErrorFormatter : IErrorResponseFormatter
{
public object Format(ApiError error) => new
{
status = "error",
message = error.Message,
code = error.Code,
data = error.Details
};
}
// Register the formatter
builder.Services.AddErrorHound(options =>
{
options.UseFormatter<JSendErrorFormatter>();
});Response:
{
"status": "error",
"message": "The requested resource could not be found.",
"code": "NOT_FOUND",
"data": "Product with ID 456 not found"
}Custom formatters can use dependency injection to access services like ILogger or IHttpContextAccessor:
public sealed class AdvancedErrorFormatter : IErrorResponseFormatter
{
private readonly ILogger<AdvancedErrorFormatter> _logger;
private readonly IHttpContextAccessor _httpContextAccessor;
public AdvancedErrorFormatter(
ILogger<AdvancedErrorFormatter> logger,
IHttpContextAccessor httpContextAccessor)
{
_logger = logger;
_httpContextAccessor = httpContextAccessor;
}
public object Format(ApiError error)
{
var traceId = _httpContextAccessor.HttpContext?.TraceIdentifier;
_logger.LogDebug("Formatting error {Code} for trace {TraceId}", error.Code, traceId);
return new
{
httpStatus = error.Status,
errorCode = error.Code,
errorMessage = error.Message,
errorDetails = error.Details,
timestamp = DateTime.UtcNow.ToString("o"),
traceId
};
}
}
// Don't forget to register IHttpContextAccessor
builder.Services.AddHttpContextAccessor();
builder.Services.AddErrorHound(options =>
{
options.UseFormatter<AdvancedErrorFormatter>();
});Response:
{
"httpStatus": 409,
"errorCode": "CONFLICT",
"errorMessage": "The request could not be completed due to a conflict.",
"errorDetails": "Email already exists",
"timestamp": "2025-12-13T15:30:00.0000000Z",
"traceId": "0HMV9C6O9N4AR:00000001"
}Important Note: When using a custom formatter, the HTTP status code from the original error is preserved. For example, a NotFoundError will still return HTTP 404, a BadRequestError will return HTTP 400, etc. Only the response body format changes based on your custom formatter.
ValidationError is a special error type for handling form validation with support for multiple errors per field.
app.MapPost("/users", (CreateUserRequest request) =>
{
var validation = new ValidationError();
if (string.IsNullOrWhiteSpace(request.Email))
validation.AddFieldError("Email", "Email is required");
if (string.IsNullOrWhiteSpace(request.Password))
validation.AddFieldError("Password", "Password is required");
if (validation.FieldErrors.Any())
throw validation;
// Create user...
});Default Response:
{
"code": "VALIDATION",
"message": "Validation failed",
"status": 400,
"details": {
"Email": ["Email is required"],
"Password": ["Password is required"]
}
}You can add multiple validation errors to the same field:
var validation = new ValidationError();
validation.AddFieldError("Email", "Email is required");
validation.AddFieldError("Email", "Email must be a valid email address");
validation.AddFieldError("Email", "Email domain is not allowed");
validation.AddFieldError("Password", "Password is required");
validation.AddFieldError("Password", "Password must be at least 8 characters");
validation.AddFieldError("Password", "Password must contain a number");
throw validation;Response:
{
"code": "VALIDATION",
"message": "Validation failed",
"status": 400,
"details": {
"Email": [
"Email is required",
"Email must be a valid email address",
"Email domain is not allowed"
],
"Password": [
"Password is required",
"Password must be at least 8 characters",
"Password must contain a number"
]
}
}You can override the default "Validation failed" message:
var validation = new ValidationError("Registration validation failed");
validation.AddFieldError("Username", "Username is already taken");
throw validation;Response:
{
"code": "VALIDATION",
"message": "Registration validation failed",
"status": 400,
"details": {
"Username": ["Username is already taken"]
}
}You can create a ValidationError with pre-populated errors:
var fieldErrors = new Dictionary<string, List<string>>
{
["Email"] = new List<string> { "Email is required" },
["Password"] = new List<string> { "Password is required", "Password is too short" }
};
throw new ValidationError("Form validation failed", fieldErrors);You can create your own error types by extending the ApiError base class. This is useful for domain-specific errors.
using System.Net;
using ErrorHound.Core;
namespace MyApp.Errors;
public sealed class PaymentFailedError : ApiError
{
public PaymentFailedError(string? details = null)
: base("PAYMENT_FAILED",
"Payment processing failed",
(int)HttpStatusCode.PaymentRequired,
details)
{
}
}Usage:
app.MapPost("/checkout", async (CheckoutRequest request, IPaymentService payment) =>
{
var result = await payment.ProcessPaymentAsync(request);
if (!result.Success)
throw new PaymentFailedError(result.ErrorMessage);
return Results.Ok(result);
});Response:
{
"code": "PAYMENT_FAILED",
"message": "Payment processing failed",
"status": 402,
"details": "Insufficient funds"
}For better organization, define error codes and messages as constants:
using System.Net;
using ErrorHound.Core;
namespace MyApp.Errors;
public static class CustomErrorCodes
{
public const string SubscriptionExpired = "SUBSCRIPTION_EXPIRED";
public const string QuotaExceeded = "QUOTA_EXCEEDED";
}
public static class CustomErrorMessages
{
public const string SubscriptionExpired = "Your subscription has expired";
public const string QuotaExceeded = "You have exceeded your usage quota";
}
public sealed class SubscriptionExpiredError : ApiError
{
public SubscriptionExpiredError(string? details = null)
: base(CustomErrorCodes.SubscriptionExpired,
CustomErrorMessages.SubscriptionExpired,
(int)HttpStatusCode.PaymentRequired,
details)
{
}
}
public sealed class QuotaExceededError : ApiError
{
public QuotaExceededError(string? details = null)
: base(CustomErrorCodes.QuotaExceeded,
CustomErrorMessages.QuotaExceeded,
(int)HttpStatusCode.TooManyRequests,
details)
{
}
}Usage:
app.MapPost("/api/process", async (ProcessRequest request, ISubscriptionService subs) =>
{
var subscription = await subs.GetSubscriptionAsync(request.UserId);
if (subscription.IsExpired)
throw new SubscriptionExpiredError($"Expired on {subscription.ExpiryDate:yyyy-MM-dd}");
if (subscription.UsageCount >= subscription.Quota)
throw new QuotaExceededError($"Limit: {subscription.Quota} requests per month");
// Process request...
});using System.Net;
using ErrorHound.Core;
namespace MyApp.Errors;
public sealed class BusinessRuleViolationError : ApiError
{
public BusinessRuleViolationError(string rule, string reason, object? additionalData = null)
: base("BUSINESS_RULE_VIOLATION",
$"Business rule '{rule}' was violated",
(int)HttpStatusCode.UnprocessableEntity,
new
{
rule,
reason,
additionalData
})
{
}
}Usage:
app.MapPost("/orders", (CreateOrderRequest request) =>
{
if (request.Items.Count > 100)
throw new BusinessRuleViolationError(
rule: "MaxOrderItems",
reason: "Orders cannot contain more than 100 items",
additionalData: new { maxItems = 100, requestedItems = request.Items.Count }
);
// Create order...
});Response:
{
"code": "BUSINESS_RULE_VIOLATION",
"message": "Business rule 'MaxOrderItems' was violated",
"status": 422,
"details": {
"rule": "MaxOrderItems",
"reason": "Orders cannot contain more than 100 items",
"additionalData": {
"maxItems": 100,
"requestedItems": 150
}
}
}using System.Net;
using ErrorHound.Core;
namespace MyApp.Errors;
public sealed class ResourceLockedError : ApiError
{
public ResourceLockedError(string resourceType, string resourceId, string lockedBy)
: base("RESOURCE_LOCKED",
$"{resourceType} is currently locked by another user",
(int)HttpStatusCode.Locked,
new
{
resourceType,
resourceId,
lockedBy,
lockedAt = DateTime.UtcNow
})
{
}
}Usage:
app.MapPut("/documents/{id}", async (int id, UpdateDocumentRequest request, IDocumentService docs) =>
{
var doc = await docs.GetAsync(id);
if (doc.IsLocked && doc.LockedBy != request.UserId)
throw new ResourceLockedError("Document", id.ToString(), doc.LockedBy);
// Update document...
});Response:
{
"code": "RESOURCE_LOCKED",
"message": "Document is currently locked by another user",
"status": 423,
"details": {
"resourceType": "Document",
"resourceId": "42",
"lockedBy": "user@example.com",
"lockedAt": "2025-12-13T15:30:00Z"
}
}Simply throw any built-in error from your endpoints or controllers:
using ErrorHound.BuiltIn;
app.MapGet("/products/{id}", async (int id, ProductService productService) =>
{
var product = await productService.GetByIdAsync(id);
if (product == null)
throw new NotFoundError($"Product with ID {id} does not exist");
return Results.Ok(product);
});Response (404):
{
"code": "NOT_FOUND",
"message": "The requested resource could not be found.",
"status": 404,
"details": "Product with ID 123 does not exist"
}ValidationError supports multiple errors per field for comprehensive form validation:
using ErrorHound.BuiltIn;
app.MapPost("/register", (RegisterRequest request) =>
{
var validation = new ValidationError();
if (string.IsNullOrWhiteSpace(request.Email))
validation.AddFieldError("Email", "Email is required");
else if (!IsValidEmail(request.Email))
validation.AddFieldError("Email", "Email must be a valid email address");
if (string.IsNullOrWhiteSpace(request.Password))
validation.AddFieldError("Password", "Password is required");
else if (request.Password.Length < 8)
validation.AddFieldError("Password", "Password must be at least 8 characters");
if (string.IsNullOrWhiteSpace(request.Username))
validation.AddFieldError("Username", "Username is required");
if (validation.FieldErrors.Any())
throw validation;
// Process registration...
return Results.Ok(new { message = "Registration successful" });
});Response (400):
{
"code": "VALIDATION",
"message": "Validation failed",
"status": 400,
"details": {
"Email": [
"Email is required"
],
"Password": [
"Password is required"
],
"Username": [
"Username is required"
]
}
}All built-in errors accept an optional details parameter:
// Simple details
throw new UnauthorizedError("Token has expired");
// Detailed object
throw new ConflictError(new
{
conflictingField = "email",
existingValue = "john@example.com",
attemptedValue = "john@example.com"
});
// Multiple details
throw new BadRequestError(new
{
invalidFields = new[] { "startDate", "endDate" },
reason = "Start date must be before end date"
});You have complete control over the error response structure by creating a custom formatter:
using ErrorHound.Abstractions;
using ErrorHound.Core;
public sealed class MyCustomFormatter : IErrorResponseFormatter
{
public object Format(ApiError error) => new
{
success = false,
errorCode = error.Code,
errorMessage = error.Message,
timestamp = DateTime.UtcNow,
data = error.Details
};
}
// Register the custom formatter
builder.Services.AddErrorHound(options =>
{
options.UseFormatter<MyCustomFormatter>();
});Custom Response Example:
{
"success": false,
"errorCode": "NOT_FOUND",
"errorMessage": "The requested resource could not be found.",
"timestamp": "2025-12-13T10:30:00Z",
"data": "Product with ID 123 does not exist"
}using ErrorHound.BuiltIn;
// Product not found
app.MapGet("/products/{id}", async (int id, IProductRepository repo) =>
{
var product = await repo.FindByIdAsync(id);
if (product == null)
throw new NotFoundError($"Product {id} not found");
return product;
});
// Insufficient stock
app.MapPost("/cart/add", async (AddToCartRequest request, ICartService cart) =>
{
var available = await cart.CheckStockAsync(request.ProductId);
if (available < request.Quantity)
throw new ConflictError($"Only {available} items available in stock");
await cart.AddItemAsync(request.ProductId, request.Quantity);
return Results.Ok();
});
// Rate limiting
app.MapPost("/orders", async (CreateOrderRequest request, IRateLimiter limiter) =>
{
if (!await limiter.AllowRequestAsync(request.UserId))
throw new TooManyRequestsError("Order rate limit exceeded. Please try again in 1 minute.");
// Process order...
return Results.Created("/orders/123", new { orderId = 123 });
});// Login endpoint
app.MapPost("/auth/login", async (LoginRequest request, IAuthService auth) =>
{
var user = await auth.FindUserByEmailAsync(request.Email);
if (user == null || !auth.VerifyPassword(user, request.Password))
throw new UnauthorizedError("Invalid email or password");
if (!user.IsEmailVerified)
throw new ForbiddenError("Please verify your email before logging in");
var token = auth.GenerateToken(user);
return new { token, userId = user.Id };
});
// Protected endpoint
app.MapGet("/profile", async (HttpContext context, IUserService users) =>
{
var userId = context.User.FindFirst("userId")?.Value;
if (string.IsNullOrEmpty(userId))
throw new UnauthorizedError("Authentication required");
var profile = await users.GetProfileAsync(int.Parse(userId));
return profile;
});app.MapPost("/users", async (CreateUserRequest request, AppDbContext db) =>
{
try
{
var user = new User { Name = request.Name, Email = request.Email };
db.Users.Add(user);
await db.SaveChangesAsync();
return Results.Created($"/users/{user.Id}", user);
}
catch (DbUpdateException ex) when (ex.InnerException?.Message.Contains("duplicate key") == true)
{
throw new ConflictError($"User with email {request.Email} already exists");
}
catch (DbUpdateException)
{
throw new DatabaseError("Failed to create user due to a database error");
}
});app.MapGet("/weather/{city}", async (string city, IWeatherApiClient weather) =>
{
try
{
return await weather.GetWeatherAsync(city);
}
catch (HttpRequestException ex) when (ex.StatusCode == System.Net.HttpStatusCode.ServiceUnavailable)
{
throw new ServiceUnavailableError("Weather service is temporarily unavailable");
}
catch (TaskCanceledException)
{
throw new TimeoutError("Weather service did not respond in time");
}
});Interface for custom error response formatters.
public interface IErrorResponseFormatter
{
object Format(ApiError error);
}Configuration options for ErrorHound middleware.
public sealed class ErrorHoundOptions
{
public void UseFormatter<T>() where T : class, IErrorResponseFormatter;
}// Register ErrorHound services
public static IServiceCollection AddErrorHound(
this IServiceCollection services,
Action<ErrorHoundOptions> configure);
// Add ErrorHound middleware to pipeline
public static IApplicationBuilder UseErrorHound(
this IApplicationBuilder app);All ErrorHound exceptions inherit from ApiError:
public abstract class ApiError : Exception
{
public string Code { get; } // Unique error code (e.g., "NOT_FOUND")
public int Status { get; } // HTTP status code (e.g., 404)
public object? Details { get; } // Optional additional details
}Special error type for field-level validation:
public sealed class ValidationError : ApiError
{
public IDictionary<string, List<string>> FieldErrors { get; }
public void AddFieldError(string field, string error);
}ErrorHound is fully tested with xUnit. Run the test suite:
dotnet test[Fact]
public async Task Endpoint_Returns_NotFoundError()
{
var response = await _client.GetAsync("/products/999");
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
var json = await response.Content.ReadAsStringAsync();
var error = JsonSerializer.Deserialize<ErrorResponse>(json);
Assert.Equal("NOT_FOUND", error.Code);
Assert.Equal(404, error.Status);
}app.UseErrorHound(); // First!
app.UseRouting();
app.UseAuthentication();
app.UseAuthorization();Choose the most appropriate error type:
// Good
throw new NotFoundError("User not found");
// Avoid generic errors
throw new BadRequestError("User not found"); // Wrong status code!// Good - actionable details
throw new BadRequestError("Start date must be before end date");
// Less helpful
throw new BadRequestError("Invalid input");// Good - user-friendly
catch (SqlException)
{
throw new DatabaseError("Failed to save user");
}
// Bad - exposes internal details
catch (SqlException ex)
{
throw new DatabaseError(ex.Message); // May contain SQL details!
}var validation = new ValidationError();
// Collect all validation errors before throwing
if (errors.Any())
throw validation;ErrorHound automatically logs all errors, but you can log additional context:
_logger.LogWarning("Failed login attempt for user {Email}", email);
throw new UnauthorizedError("Invalid credentials");Contributions are welcome! To contribute:
- Fork the repository
- Create a feature branch (
git checkout -b feature/amazing-feature) - Write tests for your changes
- Ensure all tests pass (
dotnet test) - Commit your changes (
git commit -m 'Add amazing feature') - Push to your branch (
git push origin feature/amazing-feature) - Open a Pull Request
git clone https://github.com/CydoEntis/ErrorHound.git
cd ErrorHound
dotnet restore
dotnet build
dotnet testThis project is licensed under the MIT License. See the LICENSE file for details.
- Report bugs or request features via GitHub Issues
- For questions, start a Discussion
Made with ❤️ by Cydo
