![Error handling i .NET Web APIs]()
Background
There was a time when my wife and I were both working for a U.S.-based company in Nepal. Since the platform we worked on was an e-commerce system for online food ordering, issues would often arise during our nighttime—customers facing errors while placing orders or other unexpected failures, which we’d be notified about via email.
To ensure smooth operations, I implemented global exception handling that sent automatic error alerts to the support team via email. This way, we’d immediately know when something went wrong and could take action before it affected too many customers. Given that order failures could lead to lost business, having real-time alerts was critical.
However, as a core developer, I often found myself directly involved in fixing these issues, sometimes in the middle of the night or on weekends.
I still remember one particular incident – It was midnight when I got the call. A critical API endpoint was down, and our biggest client couldn’t process orders. Scrolling through endless log files, I found the culprit – a cryptic error message with zero context: "Object reference not set to an instance of an object"
. No stack trace. No request ID. Nothing to help me understand what went wrong.
That night taught me a lesson I’ll never forget: proper error handling isn’t just nice to have – it’s essential.
I’ve spent the last decade building and maintaining .NET APIs, and I’ve seen firsthand how error handling can make or break a project. In this article, I’ll share what I’ve learned through trial and error, and many late-night debugging sessions.
The Evolution of Error Handling in .NET
When I first started working with .NET back in the Web Forms days, error handling was mostly an afterthought. We’d wrap some code in try-catch blocks, maybe redirect to an error page, and call it a day. Each developer had their own approach, leading to inconsistent implementations across projects.
Those were simpler times. Today’s distributed systems are far more complex, with microservices communicating across networks and countless potential points of failure.
ASP.NET Core brought a much-needed focus on standardized error handling. Microsoft embraced industry standards like RFC 7807 (Problem Details for HTTP APIs), and the framework now provides robust tools for handling exceptions and returning appropriate responses.
But having tools available doesn’t mean they’re being used effectively. I still encounter APIs that return nothing but a 500 status code when something goes wrong – leaving developers to guess what happened and how to fix it.
Why Good Error Handling Matters
Last year, I consulted for a company that was losing customers due to API reliability issues. Their logs were filled with exceptions, but the error responses provided to clients were so vague that even their own front-end team couldn’t figure out what was going wrong.
After implementing a comprehensive error-handling strategy, two things happened:
- Support tickets decreased significantly
- Average time-to-resolution for issues dropped by almost 70%
The reason was simple: developers could now understand what was happening and fix problems faster.
Good error handling isn’t just about catching exceptions – it’s about providing meaningful information that helps developers understand and address issues quickly. Here’s why it matters:
- Developer Experience: I’ve been on both sides of an API, and nothing frustrates me more than cryptic error messages. Clear error responses save hours of debugging time.
- Troubleshooting: When something goes wrong in production, you need to move fast. Standardized error formats with correlation IDs make tracking down issues much easier.
- Security: Proper error handling prevents sensitive information leaks while still providing actionable feedback.
- User Experience: Let’s face it – errors will happen. Good error handling allows you to translate technical issues into user-friendly messages.
- Reliability: A consistent approach to error handling makes your API more predictable and robust.
HTTP Status Codes: Speaking the Language of Web APIs
HTTP status codes are the first line of communication when something goes wrong. Using them correctly is essential for building a professional API.
During a recent code review, I spotted an endpoint that returned a 200 OK status with an error message in the response body. This anti-pattern confuses clients and breaks conventions that other developers expect.
Here’s a quick refresher on the most important status codes for APIs:
2XX – Success
- 200 OK: The request succeeded (for GET requests)
- 201 Created: A new resource was successfully created (for POST requests)
- 204 No Content: The request succeeded, but there’s nothing to return (often used for DELETE)
4XX – Client Errors
- 400 Bad Request: The client sent something invalid (malformed JSON, invalid parameters, etc.)
- 401 Unauthorized: Authentication is required but wasn’t provided
- 403 Forbidden: The client is authenticated but doesn’t have permission
- 404 Not Found: The requested resource doesn’t exist
- 409 Conflict: The request conflicts with the current state (e.g., creating a duplicate resource)
- 422 Unprocessable Entity: The request was well-formed but had semantic errors
5XX – Server Errors
- 500 Internal Server Error: Something unexpected went wrong on the server
- 502 Bad Gateway: An upstream service returned an invalid response
- 503 Service Unavailable: The server is temporarily unavailable (maintenance, overloaded)
I’ve seen too many APIs that return 500 for every error, regardless of the cause. This makes it impossible for clients to handle errors intelligently. Always return the most specific status code that applies to the situation.
Building a Robust Error Handling System in .NET
Let’s dive into practical implementation. I’ll share code examples from real projects (with names changed to protect the innocent) and explain the reasoning behind each approach.
1. Global Exception Handling
The foundation of any good error-handling strategy is global exception handling. This ensures that unhandled exceptions don’t result in default error pages or, worse, expose sensitive information.
I use a middleware approach in all my projects:
ErrorResponse.cs
namespace MyApi.ErrorHandling;
// Define a standard error response model
public class ErrorResponse
{
public string Type { get; set; } = string.Empty;
public string Title { get; set; } = string.Empty;
public int Status { get; set; }
public string TraceId { get; set; } = string.Empty;
public IDictionary<string, string[]> Errors { get; set; } = new Dictionary<string, string[]>();
}
// Custom exceptions
public class NotFoundException : Exception
{
public NotFoundException(string message) : base(message) { }
}
public class BadRequestException : Exception
{
public IDictionary<string, string[]>? ValidationErrors { get; }
public BadRequestException(string message) : base(message) { }
public BadRequestException(string message, IDictionary<string, string[]> validationErrors) : base(message)
{
ValidationErrors = validationErrors;
}
}
ExceptionHandlingExtensions.cs
// Global exception handler
public static class ExceptionHandlingExtensions
{
public static void ConfigureExceptionHandler(this WebApplication app)
{
app.UseExceptionHandler(errorApp =>
{
errorApp.Run(async context =>
{
var exceptionHandlerFeature = context.Features.Get<IExceptionHandlerFeature>();
var exception = exceptionHandlerFeature?.Error;
var errorResponse = new ErrorResponse
{
TraceId = context.TraceIdentifier
};
switch (exception)
{
case NotFoundException notFoundException:
context.Response.StatusCode = (int)HttpStatusCode.NotFound;
errorResponse.Status = (int)HttpStatusCode.NotFound;
errorResponse.Title = "Resource not found";
errorResponse.Type = "https://tools.ietf.org/html/rfc7231#section-6.5.4";
break;
case BadRequestException badRequestException:
context.Response.StatusCode = (int)HttpStatusCode.BadRequest;
errorResponse.Status = (int)HttpStatusCode.BadRequest;
errorResponse.Title = "One or more validation errors occurred";
errorResponse.Type = "https://tools.ietf.org/html/rfc7231#section-6.5.1";
if (badRequestException.ValidationErrors != null)
{
errorResponse.Errors = badRequestException.ValidationErrors;
}
break;
case UnauthorizedAccessException:
context.Response.StatusCode = (int)HttpStatusCode.Unauthorized;
errorResponse.Status = (int)HttpStatusCode.Unauthorized;
errorResponse.Title = "Authentication required";
errorResponse.Type = "https://tools.ietf.org/html/rfc7235#section-3.1";
break;
default:
context.Response.StatusCode = (int)HttpStatusCode.InternalServerError;
errorResponse.Status = (int)HttpStatusCode.InternalServerError;
errorResponse.Title = "An error occurred while processing your request";
errorResponse.Type = "https://tools.ietf.org/html/rfc7231#section-6.6.1";
// Log the exception details here
break;
}
context.Response.ContentType = "application/problem+json";
var options = new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
};
await context.Response.WriteAsync(JsonSerializer.Serialize(errorResponse, options));
});
});
}
}
This approach has several benefits
- It ensures all unhandled exceptions return a consistent response format
- It maps custom exceptions to appropriate HTTP status codes
- It includes a trace ID for correlation with logs
- It follows the RFC 7807 standard for problem details
2. Controller-Level Error Handling
While global handlers catch unhandled exceptions, I often implement more specific error handling at the controller level. This provides finer control and allows for custom responses for different scenarios.
Here’s an example from a product management API I built:
[HttpGet("{id}")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ErrorResponse), StatusCodes.Status404NotFound)]
[ProducesResponseType(typeof(ErrorResponse), StatusCodes.Status500InternalServerError)]
public async Task<ActionResult<Product>> GetProduct(int id)
{
try
{
var product = await _productService.GetProductByIdAsync(id);
if (product == null)
{
throw new NotFoundException($"Product with ID {id} not found");
}
return Ok(product);
}
catch (NotFoundException)
{
// Let the global handler catch this
throw;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error retrieving product {ProductId}", id);
throw;
}
}
Notice the ProducesResponseType
attributes. These document the possible responses in Swagger/OpenAPI, making it easier for API consumers to understand what to expect.
3. Input Validation
In my experience, a significant portion of API errors are caused by invalid input. .NET provides several options for validation:
Using Data Annotations
This is the simplest approach and works well for basic scenarios:
public class ProductCreateDto
{
[Required]
[StringLength(100)]
public string Name { get; set; } = string.Empty;
[StringLength(500)]
public string Description { get; set; } = string.Empty;
[Range(0.01, double.MaxValue)]
public decimal Price { get; set; }
[Range(1, int.MaxValue)]
public int CategoryId { get; set; }
}
Using FluentValidation
For more complex validation scenarios, I prefer FluentValidation. It’s more flexible and allows for context-dependent validation rules:
public class ProductCreateValidator : AbstractValidator<ProductCreateDto>
{
public ProductCreateValidator(IProductRepository productRepository)
{
RuleFor(p => p.Name)
.NotEmpty().WithMessage("Product name is required")
.MaximumLength(100).WithMessage("Name cannot exceed 100 characters")
.MustAsync(async (name, cancellation) =>
!await productRepository.ExistsByNameAsync(name))
.WithMessage("A product with this name already exists");
RuleFor(p => p.Description)
.MaximumLength(500).WithMessage("Description cannot exceed 500 characters");
RuleFor(p => p.Price)
.GreaterThan(0).WithMessage("Price must be greater than zero");
RuleFor(p => p.CategoryId)
.GreaterThan(0).WithMessage("Valid category must be selected")
.MustAsync(async (id, cancellation) =>
await productRepository.CategoryExistsAsync(id))
.WithMessage("Category does not exist");
}
}
The key is to integrate validation with your error-handling system. When validation fails, you should return a 400 Bad Request with detailed information about which fields failed validation and why.
4. Problem Details for HTTP APIs
.NET Core includes built-in support for RFC 7807 (Problem Details for HTTP APIs). This standard defines a common format for error responses that includes:
- A type URI that identifies the error
- A title that briefly describes the error
- An HTTP status code
- A detailed description of the error
- Additional properties specific to the error
Here’s how I implement it:
public static class ProblemDetailsExtensions
{
public static IServiceCollection AddProblemDetailsServices(this IServiceCollection services)
{
services.AddProblemDetails(options =>
{
options.CustomizeProblemDetails = context =>
{
// Add correlation ID
context.ProblemDetails.Extensions["traceId"] = context.HttpContext.TraceIdentifier;
// Add environment-specific details
if (context.HttpContext.RequestServices.GetService<IHostEnvironment>()?.IsDevelopment() == true)
{
if (context.Exception != null)
{
context.ProblemDetails.Extensions["exception"] = new
{
message = context.Exception.Message,
source = context.Exception.Source,
stackTrace = context.Exception.StackTrace
};
}
}
};
});
return services;
}
}
5. Rate Limiting and Throttling
As your API grows in popularity, you’ll likely need to implement rate limiting. When a client exceeds their quota, it’s important to provide clear information about the limit and when they can try again.
Here’s an example using .NET’s built-in rate limiting:
public static IServiceCollection AddRateLimitingServices(this IServiceCollection services)
{
services.AddRateLimiter(options =>
{
options.AddFixedWindowLimiter("fixed", options =>
{
options.PermitLimit = 10;
options.Window = TimeSpan.FromSeconds(10);
options.QueueProcessingOrder = QueueProcessingOrder.OldestFirst;
options.QueueLimit = 5;
});
options.OnRejected = async (context, token) =>
{
context.HttpContext.Response.StatusCode = StatusCodes.Status429TooManyRequests;
context.HttpContext.Response.ContentType = "application/problem+json";
var errorResponse = new ErrorResponse
{
Type = "https://tools.ietf.org/html/rfc6585#section-4",
Title = "Too many requests",
Status = StatusCodes.Status429TooManyRequests,
TraceId = context.HttpContext.TraceIdentifier
};
// Add retry header
if (context.Lease.TryGetMetadata(MetadataName.RetryAfter, out var retryAfter))
{
context.HttpContext.Response.Headers.RetryAfter = retryAfter.TotalSeconds.ToString();
errorResponse.Errors.Add("RetryAfter", new[] { $"Please retry after {retryAfter.TotalSeconds} seconds" });
}
var options = new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
};
await context.HttpContext.Response.WriteAsync(
JsonSerializer.Serialize(errorResponse, options),
token);
};
});
return services;
}
The key here is to include the Retry-After
header, which tells clients when they can make another request.
Real-World Impact of Good Error Handling
Let me share a real-world example of how good error handling transformed a struggling API.
I was brought in to help a financial services company that was struggling with API reliability. Their error responses looked like this:
{
"error": "An error occurred"
}
When something went wrong, their support team would receive emails with screenshots of this error message. They had no way to correlate these reports with specific exceptions in their logs, which made troubleshooting nearly impossible.
We implemented a comprehensive error-handling strategy, and their new error responses looked like this:
{
"type": "https://tools.ietf.org/html/rfc7231#section-6.5.1",
"title": "One or more validation errors occurred",
"status": 400,
"traceId": "00-a762ec7b5eb9a5488809d1158c5bf84c-fa7d3ce7d9f5e94e-00",
"errors": {
"AccountNumber": ["Account number must be 10 digits"],
"TransactionAmount": ["Amount must be greater than zero"]
}
}
The results were dramatic
- Support tickets decreased by 40%
- Average resolution time dropped from days to hours
- Developer onboarding time for new API consumers was cut in half
Good error handling isn’t just a technical nicety – it has a real business impact.
Best Practices I’ve Learned the Hard Way
After years of building and maintaining APIs, here are the key lessons I’ve learned:
1. Use Domain-Specific Exceptions
Create custom exceptions that map to your business domain. This makes your code more readable and helps translate technical issues into meaningful error messages:
public class InsufficientFundsException : Exception
{
public decimal AttemptedAmount { get; }
public decimal AvailableBalance { get; }
public InsufficientFundsException(decimal attempted, decimal available)
: base($"Insufficient funds. Attempted: {attempted:C}, Available: {available:C}")
{
AttemptedAmount = attempted;
AvailableBalance = available;
}
}
2. Include Correlation IDs
Every error response should include a unique identifier that can be used to correlate the error with log entries. This is invaluable for troubleshooting:
// In your error response
errorResponse.TraceId = context.TraceIdentifier;
// In your logging
_logger.LogError(ex, "Error processing payment {TraceId}", context.TraceIdentifier);
3. Document Error Responses
Use Swagger/OpenAPI to document possible error responses for each endpoint:
[HttpPost]
[ProducesResponseType(StatusCodes.Status201Created)]
[ProducesResponseType(typeof(ErrorResponse), StatusCodes.Status400BadRequest)]
[ProducesResponseType(typeof(ErrorResponse), StatusCodes.Status500InternalServerError)]
public async Task<ActionResult<Product>> CreateProduct(ProductCreateDto productDto)
{
// Implementation
}
4. Log Appropriately
Different errors warrant different logging levels:
- Critical system failures: Log as errors
- Client errors: Log as warnings
- Rate limiting: Log as information
Include contextual information in your logs, not just the exception details:
_logger.LogError(ex, "Failed to process payment {PaymentId} for customer {CustomerId}",
payment.Id, customer.Id);
5. Test Error Paths
It’s easy to focus on testing the happy path, but error scenarios need testing too. Write unit and integration tests specifically for error handling:
[Fact]
public async Task GetProduct_WithInvalidId_ReturnsNotFound()
{
// Arrange
var client = _factory.CreateClient();
// Act
var response = await client.GetAsync("/api/products/999");
// Assert
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
var content = await response.Content.ReadAsStringAsync();
var error = JsonSerializer.Deserialize<ErrorResponse>(content);
Assert.Equal("Resource not found", error.Title);
Assert.Equal(404, error.Status);
}
6. Monitor and Alert
Set up monitoring for error rates and critical failures. A sudden spike in errors often indicates an underlying issue that needs attention.
I use tools like Application Insights or Prometheus with Grafana to track error rates and set up alerts when they exceed normal thresholds.
Conclusion
Error handling might not be the most exciting part of API development, but it’s one of the most important. A well-designed error-handling strategy can dramatically improve the developer experience, reduce support costs, and make your API more robust.
Throughout my career, I’ve seen firsthand how good error handling can transform a frustrating API into a joy to work with. The extra effort pays off in fewer support tickets, faster issue resolution, and happier developers.
As you build your own .NET APIs, I encourage you to invest time in designing a thoughtful error-handling strategy. Consider the needs of your API consumers, include the information they need to troubleshoot issues, and make error responses as helpful as possible.
Remember, errors are inevitable – poor error handling isn’t.