Problem Details 与 API 错误处理
大约 9 分钟约 2587 字
Problem Details 与 API 错误处理
简介
RFC 7807 定义了 Problem Details 标准,为 HTTP API 提供了统一的错误响应格式。ASP.NET Core 8+ 深度集成了 Problem Details,理解其内部机制、自定义扩展和全局异常处理策略,有助于构建一致且可调试的 API 错误体系。
特点
Problem Details 基础
RFC 7807 格式
// RFC 7807 Problem Details 标准字段:
// type — 错误类型的 URI 标识(指向错误文档)
// title — 人类可读的错误标题
// status — HTTP 状态码
// detail — 具体错误详情
// instance — 出现问题的具体请求 URI
// ASP.NET Core 内置的 ProblemDetails 类
var problem = new ProblemDetails
{
Type = "https://tools.ietf.org/html/rfc9110#section-15.5.1",
Title = "Bad Request",
Status = 400,
Detail = "The specified name is invalid.",
Instance = "/api/users"
};
// Extensions 字典扩展自定义字段
problem.Extensions["traceId"] = "00-abc123-def456-01";
problem.Extensions["timestamp"] = DateTime.UtcNow;
problem.Extensions["errorCode"] = "USER_NAME_INVALID";
// 序列化结果:
// {
// "type": "https://tools.ietf.org/html/rfc9110#section-15.5.1",
// "title": "Bad Request",
// "status": 400,
// "detail": "The specified name is invalid.",
// "instance": "/api/users",
// "traceId": "00-abc123-def456-01",
// "timestamp": "2024-01-15T10:30:00Z",
// "errorCode": "USER_NAME_INVALID"
// }ASP.NET Core 集成
// .NET 8+ 自动启用 Problem Details
// 控制器 API 自动启用,Minimal API 需要手动添加
// Minimal API 启用
builder.Services.AddProblemDetails();
// 配置全局默认值
builder.Services.AddProblemDetails(options =>
{
options.CustomizeProblemDetails = context =>
{
// 所有错误响应自动添加的字段
context.ProblemDetails.Extensions["traceId"] =
Activity.Current?.Id ?? context.HttpContext.TraceIdentifier;
context.ProblemDetails.Extensions["requestId"] =
context.HttpContext.TraceIdentifier;
context.ProblemDetails.Extensions["timestamp"] =
DateTime.UtcNow.ToString("O");
context.ProblemDetails.Extensions["service"] =
builder.Environment.ApplicationName;
};
});
// 返回 Problem Details
app.MapGet("/api/users/{id}", (int id) =>
{
if (id <= 0)
{
return Results.Problem(
title: "Invalid User ID",
detail: $"User ID must be positive, got {id}",
statusCode: 400,
type: "https://example.com/errors/invalid-id");
}
return Results.Ok(new { Id = id, Name = "Test" });
});
// 使用 Results.ValidationProblem 返回验证错误
app.MapPost("/api/users", (CreateUserRequest request) =>
{
var errors = new Dictionary<string, string[]>
{
{ "Name", new[] { "名称不能为空" } },
{ "Email", new[] { "邮箱格式不正确", "邮箱已被注册" } }
};
return Results.ValidationProblem(errors, title: "Validation Failed");
});自定义错误类型
派生 ProblemDetails
// 自定义错误类型继承 ProblemDetails
public class BusinessProblemDetails : ProblemDetails
{
public string? ErrorCode { get; set; }
public string? HelpLink { get; set; }
}
// 错误码常量
public static class ErrorCodes
{
public const string USER_NOT_FOUND = "USER_NOT_FOUND";
public const string DUPLICATE_EMAIL = "DUPLICATE_EMAIL";
public const string INSUFFICIENT_STOCK = "INSUFFICIENT_STOCK";
public const string ORDER_EXPIRED = "ORDER_EXPIRED";
}
// 错误类型注册表
public static class ProblemTypes
{
private static readonly string BaseUrl = "https://example.com/errors";
public static string UserNotFound => $"{BaseUrl}/user-not-found";
public static string DuplicateEmail => $"{BaseUrl}/duplicate-email";
public static string InsufficientStock => $"{BaseUrl}/insufficient-stock";
public static string ValidationError => $"{BaseUrl}/validation-error";
public static string RateLimitExceeded => $"{BaseUrl}/rate-limit-exceeded";
public static string InternalError => $"{BaseUrl}/internal-error";
}
// 封装错误响应
public static class ProblemResults
{
public static IResult UserNotFound(int userId)
{
return Results.Problem(
title: "User Not Found",
detail: $"User with ID {userId} does not exist.",
statusCode: 404,
type: ProblemTypes.UserNotFound,
extensions: new Dictionary<string, object?>
{
["errorCode"] = ErrorCodes.USER_NOT_FOUND,
["userId"] = userId
});
}
public static IResult DuplicateEmail(string email)
{
return Results.Problem(
title: "Duplicate Email",
detail: $"The email '{email}' is already registered.",
statusCode: 409,
type: ProblemTypes.DuplicateEmail,
extensions: new Dictionary<string, object?>
{
["errorCode"] = ErrorCodes.DUPLICATE_EMAIL
});
}
public static IResult InsufficientStock(string product, int requested, int available)
{
return Results.Problem(
title: "Insufficient Stock",
detail: $"Product '{product}' has only {available} items, requested {requested}.",
statusCode: 409,
type: ProblemTypes.InsufficientStock,
extensions: new Dictionary<string, object?>
{
["errorCode"] = ErrorCodes.INSUFFICIENT_STOCK,
["product"] = product,
["requested"] = requested,
["available"] = available
});
}
}
// 使用
app.MapGet("/api/users/{id}", (int id) =>
{
var user = userService.GetById(id);
if (user == null) return ProblemResults.UserNotFound(id);
return Results.Ok(user);
});
app.MapPost("/api/orders", (CreateOrderRequest request) =>
{
var stock = inventoryService.GetStock(request.ProductId);
if (stock < request.Quantity)
return ProblemResults.InsufficientStock(request.ProductName, request.Quantity, stock);
return Results.Created();
});全局异常处理
IExceptionHandler
// .NET 8+ 全局异常处理器
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)
{
var traceId = Activity.Current?.Id ?? httpContext.TraceIdentifier;
// 根据异常类型返回不同的 Problem Details
var problemDetails = exception switch
{
NotFoundException ex => new ProblemDetails
{
Type = ProblemTypes.UserNotFound,
Title = "Resource Not Found",
Status = StatusCodes.Status404NotFound,
Detail = ex.Message,
Instance = httpContext.Request.Path
},
BusinessException ex => new ProblemDetails
{
Type = "https://example.com/errors/business-error",
Title = "Business Rule Violation",
Status = StatusCodes.Status400BadRequest,
Detail = ex.Message,
Instance = httpContext.Request.Path
},
ValidationException ex => new ProblemDetails
{
Type = ProblemTypes.ValidationError,
Title = "Validation Failed",
Status = StatusCodes.Status400BadRequest,
Detail = ex.Message,
Instance = httpContext.Request.Path
},
UnauthorizedAccessException => new ProblemDetails
{
Type = "https://tools.ietf.org/html/rfc9110#section-15.5.2",
Title = "Unauthorized",
Status = StatusCodes.Status401Unauthorized,
Detail = "Authentication required.",
Instance = httpContext.Request.Path
},
TimeoutException => new ProblemDetails
{
Type = "https://tools.ietf.org/html/rfc9110#section-15.5.9",
Title = "Request Timeout",
Status = StatusCodes.Status408RequestTimeout,
Detail = "The request timed out.",
Instance = httpContext.Request.Path
},
_ => new ProblemDetails
{
Type = ProblemTypes.InternalError,
Title = "Internal Server Error",
Status = StatusCodes.Status500InternalServerError,
Detail = "An unexpected error occurred.",
Instance = httpContext.Request.Path
}
};
// 添加公共扩展
problemDetails.Extensions["traceId"] = traceId;
problemDetails.Extensions["timestamp"] = DateTime.UtcNow.ToString("O");
// 记录日志
_logger.LogError(exception,
"Unhandled exception: {Type} - {Message}. TraceId: {TraceId}",
exception.GetType().Name, exception.Message, traceId);
// 写入响应
httpContext.Response.StatusCode = problemDetails.Status!.Value;
httpContext.Response.ContentType = "application/problem+json";
await httpContext.Response.WriteAsJsonAsync(problemDetails, cancellationToken);
return true; // 异常已处理
}
}
// 注册
builder.Services.AddExceptionHandler<GlobalExceptionHandler>();
builder.Services.AddProblemDetails();
// 启用(必须放在管道早期)
app.UseExceptionHandler();异常中间件方式
// 兼容旧版本的异常处理中间件
public class ExceptionHandlingMiddleware
{
private readonly RequestDelegate _next;
private readonly ILogger<ExceptionHandlingMiddleware> _logger;
private readonly IHostEnvironment _env;
public ExceptionHandlingMiddleware(
RequestDelegate next,
ILogger<ExceptionHandlingMiddleware> logger,
IHostEnvironment env)
{
_next = next;
_logger = logger;
_env = env;
}
public async Task InvokeAsync(HttpContext context)
{
try
{
await _next(context);
}
catch (Exception ex)
{
await HandleExceptionAsync(context, ex);
}
}
private async Task HandleExceptionAsync(HttpContext context, Exception exception)
{
var traceId = Activity.Current?.Id ?? context.TraceIdentifier;
_logger.LogError(exception,
"Unhandled exception at {Path}. TraceId: {TraceId}",
context.Request.Path, traceId);
var problem = new ProblemDetails
{
Type = ProblemTypes.InternalError,
Title = "Internal Server Error",
Status = StatusCodes.Status500InternalServerError,
Detail = _env.IsDevelopment()
? exception.Message
: "An unexpected error occurred.",
Instance = context.Request.Path
};
problem.Extensions["traceId"] = traceId;
// 开发环境包含堆栈
if (_env.IsDevelopment())
{
problem.Extensions["stackTrace"] = exception.StackTrace;
problem.Extensions["innerError"] = exception.InnerException?.Message;
}
context.Response.StatusCode = problem.Status.Value;
context.Response.ContentType = "application/problem+json";
await context.Response.WriteAsJsonAsync(problem);
}
}
app.UseMiddleware<ExceptionHandlingMiddleware>();验证错误集成
自动验证错误
// Minimal API + DataAnnotation 验证
// 验证失败自动返回 Problem Details 格式
public record CreateProductRequest(
[Required] string Name,
[Range(0.01, 999999)] decimal Price,
[StringLength(500)] string? Description,
[MinLength(1)] List<string> Tags);
app.MapPost("/api/products", (CreateProductRequest request) =>
{
// 验证失败自动返回 400 Problem Details
return Results.Created("/api/products/1", request);
});
// 验证失败响应:
// {
// "type": "https://tools.ietf.org/html/rfc9110#section-15.5.1",
// "title": "One or more validation errors occurred.",
// "status": 400,
// "errors": {
// "Name": ["The Name field is required."],
// "Price": ["The field Price must be between 0.01 and 999999."],
// "Tags": ["The field Tags must be a string or array type with a minimum length of '1'."]
// },
// "traceId": "00-abc123-def456-01"
// }
// FluentValidation 集成
public class CreateProductValidator : AbstractValidator<CreateProductRequest>
{
public CreateProductValidator()
{
RuleFor(x => x.Name)
.NotEmpty().WithMessage("产品名称不能为空")
.MaximumLength(200).WithMessage("产品名称不能超过200字符");
RuleFor(x => x.Price)
.GreaterThan(0).WithMessage("价格必须大于0");
RuleFor(x => x.Tags)
.NotEmpty().WithMessage("至少需要一个标签");
}
}
app.MapPost("/api/products/validate", async (
CreateProductRequest request,
IValidator<CreateProductRequest> validator) =>
{
var result = await validator.ValidateAsync(request);
if (!result.IsValid)
{
var errors = result.ToDictionary();
return Results.ValidationProblem(errors,
title: "Validation Failed",
type: ProblemTypes.ValidationError);
}
return Results.Ok(request);
});内容协商
Problem Details 格式协商
// Problem Details 支持多种内容格式
// 默认:application/problem+json
// 可配置 XML 格式
builder.Services.AddControllers()
.AddXmlSerializerFormatters();
// 自定义 ProblemDetails 输出格式化器
public class ProblemDetailsXmlFormatter : TextOutputFormatter
{
public ProblemDetailsXmlFormatter()
{
SupportedMediaTypes.Add("application/problem+xml");
SupportedEncodings.Add(Encoding.UTF8);
}
public override async Task WriteResponseBodyAsync(
OutputFormatterWriteContext context, Encoding selectedEncoding)
{
if (context.Object is ProblemDetails problem)
{
var serializer = new XmlSerializer(typeof(ProblemDetails));
await using var writer = context.WriterFactory(
context.HttpContext.Response.Body, selectedEncoding);
serializer.Serialize(writer, problem);
}
}
protected override bool CanWriteType(Type? type)
=> type == typeof(ProblemDetails) || type?.IsSubclassOf(typeof(ProblemDetails)) == true;
}
// 客户端请求示例:
// Accept: application/problem+json → JSON 格式
// Accept: application/problem+xml → XML 格式优点
缺点
总结
RFC 7807 Problem Details 为 HTTP API 提供了标准化的错误响应格式。ASP.NET Core 8+ 通过 AddProblemDetails() 自动集成,验证错误自动转换为 Problem Details 格式。自定义错误通过 Results.Problem() 返回,支持 type、title、detail、extensions 字段。全局异常处理使用 IExceptionHandler 接口,按异常类型映射为不同的 Problem Details。建议建立错误码常量类和错误类型注册表,保持错误响应的一致性。
关键知识点
- 先分清这个主题位于请求链路、后台任务链路还是基础设施链路。
- 服务端主题通常不只关心功能正确,还关心稳定性、性能和可观测性。
- 任何框架能力都要结合配置、生命周期、异常传播和外部依赖一起看。
- 框架能力的真正重点是它在请求链路中的位置和对上下游的影响。
项目落地视角
- 画清请求进入、业务执行、外部调用、日志记录和错误返回的完整路径。
- 为关键链路补齐超时、重试、熔断、追踪和结构化日志。
- 把配置与敏感信息分离,并明确不同环境的差异来源。
- 画清执行顺序、入参来源、失败返回和日志记录点。
常见误区
- 只会堆中间件或组件,不知道它们在链路中的执行顺序。
- 忽略生命周期和线程池、连接池等运行时资源约束。
- 没有监控和测试就对性能或可靠性下结论。
- 知道 API 名称,却不知道它应该放在请求链路的哪个位置。
进阶路线
- 继续向运行时行为、可观测性、发布治理和微服务协同深入。
- 把主题和数据库、缓存、消息队列、认证授权联动起来理解。
- 沉淀团队级模板,包括统一异常处理、配置约定和基础设施封装。
- 继续补齐协议选型、网关治理、端点可观测性和契约演进策略。
适用场景
- 当你准备把《Problem Details 与 API 错误处理》真正落到项目里时,最适合先在一个独立模块或最小样例里验证关键路径。
- 适合 API 服务、后台任务、实时通信、认证授权和微服务协作场景。
- 当需求开始涉及稳定性、性能、可观测性和发布流程时,这类主题会成为基础设施能力。
落地建议
- 先定义请求链路与失败路径,再决定中间件、过滤器、服务边界和依赖方式。
- 为关键链路补日志、指标、追踪、超时与重试策略。
- 环境配置与敏感信息分离,避免把生产参数写死在代码或镜像里。
排错清单
- 先确认问题发生在路由、模型绑定、中间件、业务层还是基础设施层。
- 检查 DI 生命周期、配置来源、序列化规则和认证上下文。
- 查看线程池、连接池、缓存命中率和外部依赖超时。
复盘问题
- 如果把《Problem Details 与 API 错误处理》放进你的当前项目,最先要验证的输入、输出和失败路径分别是什么?
- 《Problem Details 与 API 错误处理》最容易在什么规模、什么边界条件下暴露问题?你会用什么指标或日志去确认?
- 相比默认实现或替代方案,采用《Problem Details 与 API 错误处理》最大的收益和代价分别是什么?
