Skip to content

CydoEntis/ErrorHound

Repository files navigation

ErrorHound Logo

ErrorHound

License NuGet Downloads

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 with success, error, and meta fields. This provides consistency with SuccessHound responses. See the CHANGELOG for migration details.

Table of Contents


Features

  • 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

Installation

Install ErrorHound via NuGet Package Manager:

dotnet add package ErrorHound

Or via Package Manager Console:

Install-Package ErrorHound

Quick Start

Here'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"
  }
}

Setup Guide

Minimal APIs (WebApplication)

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();

Classic ASP.NET Core (IApplicationBuilder)

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.


Built-in Errors

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"

Using All Error Types

Here's how to use each built-in error type with practical examples:

BadRequestError (400)

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"
}

UnauthorizedError (401)

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"
}

ForbiddenError (403)

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"
}

NotFoundError (404)

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"
}

ConflictError (409)

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"
}

TooManyRequestsError (429)

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."
}

InternalServerError (500)

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."
}

DatabaseError (500)

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"
}

ServiceUnavailableError (503)

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."
}

TimeoutError (504)

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."
}

Response Formats

Default Response Format

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"
  }
}

Custom Error Formatters

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.

Example 1: Simple Custom Formatter

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!

Example 2: Envelope Pattern with Metadata

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"
  }
}

Example 3: JSend-Style Formatter

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"
}

Example 4: Formatter with Dependency Injection

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.


Validation Errors

ValidationError is a special error type for handling form validation with support for multiple errors per field.

Basic Usage

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"]
  }
}

Multiple Errors Per Field

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"
    ]
  }
}

Custom Validation Message

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"]
  }
}

Pre-populated Field Errors

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);

Creating Custom Errors

You can create your own error types by extending the ApiError base class. This is useful for domain-specific errors.

Basic Custom Error

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"
}

Custom Error with Constants

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...
});

Custom Error with Rich Details

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
    }
  }
}

Custom Error with Parameterized Message

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"
  }
}

Usage Examples

Basic Error Handling

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"
}

Validation Errors

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"
    ]
  }
}

Adding Details to Errors

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"
});

Custom Error Formatter

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"
}

Real-World Scenarios

E-Commerce API

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 });
});

User Authentication

// 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;
});

Database Operations

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");
    }
});

External API Calls

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");
    }
});

API Reference

IErrorResponseFormatter

Interface for custom error response formatters.

public interface IErrorResponseFormatter
{
    object Format(ApiError error);
}

ErrorHoundOptions

Configuration options for ErrorHound middleware.

public sealed class ErrorHoundOptions
{
    public void UseFormatter<T>() where T : class, IErrorResponseFormatter;
}

Extension Methods

// 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);

ApiError Base Class

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
}

ValidationError

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);
}

Testing

ErrorHound is fully tested with xUnit. Run the test suite:

dotnet test

Example 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);
}

Best Practices

1. Place ErrorHound Early in the Pipeline

app.UseErrorHound();  // First!
app.UseRouting();
app.UseAuthentication();
app.UseAuthorization();

2. Use Specific Errors

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!

3. Provide Helpful Details

// Good - actionable details
throw new BadRequestError("Start date must be before end date");

// Less helpful
throw new BadRequestError("Invalid input");

4. Don't Over-Expose Internal Details

// 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!
}

5. Use Validation Errors for Form Validation

var validation = new ValidationError();

// Collect all validation errors before throwing
if (errors.Any())
    throw validation;

6. Log Before Throwing (if needed)

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");

Contributing

Contributions are welcome! To contribute:

  1. Fork the repository
  2. Create a feature branch (git checkout -b feature/amazing-feature)
  3. Write tests for your changes
  4. Ensure all tests pass (dotnet test)
  5. Commit your changes (git commit -m 'Add amazing feature')
  6. Push to your branch (git push origin feature/amazing-feature)
  7. Open a Pull Request

Development Setup

git clone https://github.com/CydoEntis/ErrorHound.git
cd ErrorHound
dotnet restore
dotnet build
dotnet test

License

This project is licensed under the MIT License. See the LICENSE file for details.


Questions or Issues?


Made with ❤️ by Cydo

About

Consistent error handling library for ASP.NET Minimal APIs

Resources

License

Stars

Watchers

Forks

Packages

 
 
 

Contributors

Languages