Error handling for ASP.NET Core APIs — exception middleware, ProblemDetails,
94
90%
Does it follow best practices?
Impact
100%
1.13xAverage score across 5 eval scenarios
Passed
No known issues
Structured, secure error handling for ASP.NET Core APIs. Every API must return consistent machine-readable error responses, never leak internal details, and log enough context for debugging.
IExceptionHandler (ASP.NET 8+) or custom middleware.errors dictionary. Use InvalidModelStateResponseFactory or FluentValidation.ILogger — include request path, correlation ID, and exception details. Never use Console.WriteLine for error logging.OperationCanceledException from cancelled requests gracefully — return 499 or suppress the log, do not treat as 500.app.UseStatusCodePages to catch non-exception error status codes (404 from missing routes, 405 method not allowed) and return ProblemDetails for those too.Content-Type to application/problem+json for all error responses.// Exceptions/ApiException.cs
public abstract class ApiException : Exception
{
public int StatusCode { get; }
public string ErrorCode { get; }
protected ApiException(int statusCode, string errorCode, string message)
: base(message)
{
StatusCode = statusCode;
ErrorCode = errorCode;
}
protected ApiException(int statusCode, string errorCode, string message, Exception inner)
: base(message, inner)
{
StatusCode = statusCode;
ErrorCode = errorCode;
}
}
public class NotFoundException : ApiException
{
public NotFoundException(string resource, object id)
: base(404, "RESOURCE_NOT_FOUND", $"{resource} with ID '{id}' was not found") { }
}
public class ConflictException : ApiException
{
public ConflictException(string message)
: base(409, "CONFLICT", message) { }
}
public class BusinessRuleException : ApiException
{
public BusinessRuleException(string message)
: base(422, "BUSINESS_RULE_VIOLATION", message) { }
}
public class ForbiddenException : ApiException
{
public ForbiddenException(string message = "You do not have permission to perform this action")
: base(403, "FORBIDDEN", message) { }
}// WRONG: No status code mapping, leaks details, inconsistent format
throw new Exception("Order not found");
throw new InvalidOperationException("Cannot cancel shipped order");
// WRONG: Returning error responses manually in every action
[HttpGet("{id}")]
public IActionResult Get(int id)
{
var item = _db.Items.Find(id);
if (item == null)
return NotFound(new { error = "not found" }); // inconsistent format
return Ok(item);
}// ExceptionHandling/GlobalExceptionHandler.cs
using Microsoft.AspNetCore.Diagnostics;
using Microsoft.AspNetCore.Mvc;
public class GlobalExceptionHandler : IExceptionHandler
{
private readonly ILogger<GlobalExceptionHandler> _logger;
public GlobalExceptionHandler(ILogger<GlobalExceptionHandler> logger)
{
_logger = logger;
}
public async ValueTask<bool> TryHandleAsync(
HttpContext httpContext,
Exception exception,
CancellationToken cancellationToken)
{
// Let cancellation bubble up without logging as error
if (exception is OperationCanceledException)
{
_logger.LogInformation("Request was cancelled: {Path}", httpContext.Request.Path);
httpContext.Response.StatusCode = 499; // Client Closed Request
return true;
}
var problemDetails = exception switch
{
ApiException apiEx => new ProblemDetails
{
Status = apiEx.StatusCode,
Title = apiEx.ErrorCode,
Detail = apiEx.Message,
Instance = httpContext.Request.Path,
Type = $"https://api.example.com/errors/{apiEx.ErrorCode.ToLowerInvariant()}"
},
_ => new ProblemDetails
{
Status = StatusCodes.Status500InternalServerError,
Title = "INTERNAL_SERVER_ERROR",
Detail = "An unexpected error occurred. Please try again later.",
Instance = httpContext.Request.Path
}
};
// Structured logging — full exception for unexpected errors
if (exception is ApiException)
{
_logger.LogWarning(exception,
"API exception {ErrorCode} on {Method} {Path}",
((ApiException)exception).ErrorCode,
httpContext.Request.Method,
httpContext.Request.Path);
}
else
{
_logger.LogError(exception,
"Unhandled exception on {Method} {Path}",
httpContext.Request.Method,
httpContext.Request.Path);
}
httpContext.Response.StatusCode = problemDetails.Status ?? 500;
httpContext.Response.ContentType = "application/problem+json";
await httpContext.Response.WriteAsJsonAsync(problemDetails, cancellationToken);
return true;
}
}// Program.cs
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddExceptionHandler<GlobalExceptionHandler>();
builder.Services.AddProblemDetails();
var app = builder.Build();
// Exception handler FIRST in pipeline
app.UseExceptionHandler();
app.UseStatusCodePages(); // Catches 404s from missing routes, etc.
app.UseHttpsRedirection();
app.UseAuthorization();
app.MapControllers();
app.Run();// WRONG: No exception handler — unhandled exceptions produce raw 500
var app = builder.Build();
app.MapControllers();
app.Run();
// WRONG: Developer exception page in production leaks stack traces
if (app.Environment.IsDevelopment())
app.UseDeveloperExceptionPage();
else
app.UseDeveloperExceptionPage(); // Copy-paste bug — never use in production!
// WRONG: Catching exceptions but returning stack trace
catch (Exception ex)
{
await context.Response.WriteAsJsonAsync(new
{
error = ex.Message,
stackTrace = ex.StackTrace // NEVER send to client
});
}// Middleware/ExceptionMiddleware.cs
public class ExceptionMiddleware
{
private readonly RequestDelegate _next;
private readonly ILogger<ExceptionMiddleware> _logger;
private readonly IHostEnvironment _env;
public ExceptionMiddleware(RequestDelegate next, ILogger<ExceptionMiddleware> logger, IHostEnvironment env)
{
_next = next;
_logger = logger;
_env = env;
}
public async Task InvokeAsync(HttpContext context)
{
try
{
await _next(context);
}
catch (OperationCanceledException) when (context.RequestAborted.IsCancellationRequested)
{
_logger.LogInformation("Request cancelled by client: {Path}", context.Request.Path);
context.Response.StatusCode = 499;
}
catch (ApiException ex)
{
_logger.LogWarning(ex, "API error {ErrorCode} on {Path}", ex.ErrorCode, context.Request.Path);
context.Response.StatusCode = ex.StatusCode;
context.Response.ContentType = "application/problem+json";
await context.Response.WriteAsJsonAsync(new ProblemDetails
{
Status = ex.StatusCode,
Title = ex.ErrorCode,
Detail = ex.Message,
Instance = context.Request.Path
});
}
catch (Exception ex)
{
_logger.LogError(ex, "Unhandled exception on {Method} {Path}", context.Request.Method, context.Request.Path);
context.Response.StatusCode = 500;
context.Response.ContentType = "application/problem+json";
await context.Response.WriteAsJsonAsync(new ProblemDetails
{
Status = 500,
Title = "INTERNAL_SERVER_ERROR",
Detail = "An unexpected error occurred. Please try again later."
});
}
}
}
// Program.cs — register FIRST in the pipeline
app.UseMiddleware<ExceptionMiddleware>();// Program.cs
builder.Services.AddControllers()
.ConfigureApiBehaviorOptions(options =>
{
options.InvalidModelStateResponseFactory = context =>
{
var problemDetails = new ValidationProblemDetails(context.ModelState)
{
Status = StatusCodes.Status400BadRequest,
Title = "VALIDATION_ERROR",
Detail = "One or more validation errors occurred.",
Instance = context.HttpContext.Request.Path,
Type = "https://api.example.com/errors/validation-error"
};
return new BadRequestObjectResult(problemDetails)
{
ContentTypes = { "application/problem+json" }
};
};
});// WRONG: Default returns a different shape than your exception handler
// Client gets inconsistent error formats depending on error source
builder.Services.AddControllers(); // No customization — format mismatch[ApiController]
[Route("api/[controller]")]
public class OrdersController : ControllerBase
{
private readonly IOrderService _orderService;
public OrdersController(IOrderService orderService) => _orderService = orderService;
[HttpGet("{id}")]
public async Task<IActionResult> GetOrder(int id, CancellationToken cancellationToken)
{
var order = await _orderService.GetByIdAsync(id, cancellationToken)
?? throw new NotFoundException("Order", id);
return Ok(order);
}
[HttpPost("{id}/cancel")]
public async Task<IActionResult> CancelOrder(int id, CancellationToken cancellationToken)
{
var order = await _orderService.GetByIdAsync(id, cancellationToken)
?? throw new NotFoundException("Order", id);
if (order.Status == OrderStatus.Shipped)
throw new BusinessRuleException("Cannot cancel an order that has already been shipped");
await _orderService.CancelAsync(id, cancellationToken);
return NoContent();
}
}// WRONG: Duplicated error handling in every action
[HttpGet("{id}")]
public async Task<IActionResult> GetOrder(int id) // Missing CancellationToken
{
try
{
var order = await _orderService.GetByIdAsync(id);
if (order == null)
return NotFound(new { message = "not found" }); // inconsistent format
return Ok(order);
}
catch (Exception ex)
{
return StatusCode(500, new { error = ex.Message }); // leaks details
}
}_logger.LogError(exception,
"Failed to process order {OrderId} for customer {CustomerId} on {Method} {Path}",
orderId, customerId, context.Request.Method, context.Request.Path);// WRONG: String interpolation defeats structured logging
_logger.LogError($"Failed to process order {orderId}: {exception.Message}");
// WRONG: Console output instead of ILogger
Console.WriteLine($"Error: {exception}");IExceptionHandler on ASP.NET 8+ or custom middleware)application/problem+json for error responsesILogger.LogError and structured parametersOperationCanceledException handled gracefully (not logged as error, not returned as 500)UseStatusCodePages configured for non-exception error status codesCancellationToken parameterevals
scenario-1
scenario-2
scenario-3
scenario-4
scenario-5
skills
aspnet-error-handling
verifiers