Problem Details 标准
大约 11 分钟约 3208 字
Problem Details 标准
简介
Problem Details 是 RFC 7807 定义的标准错误响应格式,用于把 API 错误统一表示为结构化 JSON,而不是零散的字符串或随意拼装的对象。它的价值不只是“格式统一”,更重要的是让前端、网关、日志平台、监控系统都能稳定识别错误类型、状态码、追踪号和扩展字段。
特点
实现
注册 ProblemDetails 与全局异常映射
// Program.cs
using Microsoft.AspNetCore.Mvc;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddProblemDetails(options =>
{
options.CustomizeProblemDetails = context =>
{
context.ProblemDetails.Extensions["traceId"] = context.HttpContext.TraceIdentifier;
context.ProblemDetails.Extensions["timestamp"] = DateTimeOffset.UtcNow;
if (context.Exception is DomainException domainException)
{
context.ProblemDetails.Extensions["errorCode"] = domainException.Code;
}
};
});
builder.Services.AddControllers();
var app = builder.Build();
app.UseExceptionHandler();
app.UseStatusCodePages();
app.MapControllers();
app.Run();// 业务异常定义
public class DomainException : Exception
{
public string Code { get; }
public int StatusCode { get; }
public DomainException(string code, string message, int statusCode = StatusCodes.Status400BadRequest)
: base(message)
{
Code = code;
StatusCode = statusCode;
}
}
public class NotFoundDomainException : DomainException
{
public NotFoundDomainException(string message)
: base("resource_not_found", message, StatusCodes.Status404NotFound)
{
}
}// 自定义异常处理中间件/处理器
using Microsoft.AspNetCore.Diagnostics;
using Microsoft.AspNetCore.Mvc;
public class GlobalExceptionHandler : IExceptionHandler
{
private readonly IProblemDetailsService _problemDetailsService;
private readonly ILogger<GlobalExceptionHandler> _logger;
public GlobalExceptionHandler(
IProblemDetailsService problemDetailsService,
ILogger<GlobalExceptionHandler> logger)
{
_problemDetailsService = problemDetailsService;
_logger = logger;
}
public async ValueTask<bool> TryHandleAsync(HttpContext httpContext, Exception exception, CancellationToken cancellationToken)
{
_logger.LogError(exception, "Unhandled exception. TraceId={TraceId}", httpContext.TraceIdentifier);
var statusCode = exception switch
{
DomainException domainException => domainException.StatusCode,
KeyNotFoundException => StatusCodes.Status404NotFound,
UnauthorizedAccessException => StatusCodes.Status403Forbidden,
_ => StatusCodes.Status500InternalServerError
};
var problem = new ProblemDetails
{
Type = GetProblemType(statusCode),
Title = GetProblemTitle(statusCode),
Status = statusCode,
Detail = exception is DomainException ? exception.Message : "服务器发生未处理异常",
Instance = httpContext.Request.Path
};
if (exception is DomainException domainException2)
{
problem.Extensions["errorCode"] = domainException2.Code;
}
return await _problemDetailsService.TryWriteAsync(new ProblemDetailsContext
{
HttpContext = httpContext,
ProblemDetails = problem,
Exception = exception
});
}
private static string GetProblemType(int statusCode) => statusCode switch
{
400 => "https://httpstatuses.com/400",
403 => "https://httpstatuses.com/403",
404 => "https://httpstatuses.com/404",
409 => "https://httpstatuses.com/409",
_ => "https://httpstatuses.com/500"
};
private static string GetProblemTitle(int statusCode) => statusCode switch
{
400 => "Bad Request",
403 => "Forbidden",
404 => "Not Found",
409 => "Conflict",
_ => "Internal Server Error"
};
}builder.Services.AddExceptionHandler<GlobalExceptionHandler>();在业务接口中返回标准化错误
[ApiController]
[Route("api/orders")]
public class OrdersController : ControllerBase
{
[HttpGet("{id:long}")]
public IActionResult Get(long id)
{
if (id <= 0)
{
return Problem(
title: "订单编号非法",
detail: "订单编号必须大于 0",
statusCode: StatusCodes.Status400BadRequest,
type: "https://api.example.com/problems/invalid-order-id");
}
throw new NotFoundDomainException($"订单 {id} 不存在");
}
}// Minimal API 返回 ProblemDetails
app.MapPost("/api/payments", (PaymentRequest request) =>
{
if (request.Amount <= 0)
{
return Results.Problem(
title: "金额非法",
detail: "支付金额必须大于 0",
statusCode: StatusCodes.Status400BadRequest,
type: "https://api.example.com/problems/invalid-payment-amount",
extensions: new Dictionary<string, object?>
{
["errorCode"] = "payment_amount_invalid"
});
}
return Results.Ok();
});// 返回示例
{
"type": "https://api.example.com/problems/invalid-payment-amount",
"title": "金额非法",
"status": 400,
"detail": "支付金额必须大于 0",
"instance": "/api/payments",
"traceId": "00-6d62c4f1f6b7d1f5f7d6a0e4fdc7b8d1-01",
"timestamp": "2026-04-12T10:00:00+00:00",
"errorCode": "payment_amount_invalid"
}验证错误与字段级错误输出
public class CreateUserRequest
{
[Required]
[EmailAddress]
public string Email { get; set; } = string.Empty;
[Required]
[StringLength(20, MinimumLength = 6)]
public string Password { get; set; } = string.Empty;
}[ApiController]
[Route("api/users")]
public class UsersController : ControllerBase
{
[HttpPost]
public IActionResult Create(CreateUserRequest request)
{
return Ok(new { request.Email });
}
}// ApiController 自动返回 ValidationProblemDetails
{
"type": "https://tools.ietf.org/html/rfc9110#section-15.5.1",
"title": "One or more validation errors occurred.",
"status": 400,
"errors": {
"Email": [ "The Email field is not a valid e-mail address." ],
"Password": [ "The field Password must be a string with a minimum length of 6 and a maximum length of 20." ]
},
"traceId": "00-fd734..."
}// 自定义验证失败结构
builder.Services.Configure<ApiBehaviorOptions>(options =>
{
options.InvalidModelStateResponseFactory = context =>
{
var problemDetails = new ValidationProblemDetails(context.ModelState)
{
Type = "https://api.example.com/problems/validation-error",
Title = "请求参数校验失败",
Status = StatusCodes.Status400BadRequest,
Detail = "请检查请求参数后重试",
Instance = context.HttpContext.Request.Path
};
problemDetails.Extensions["traceId"] = context.HttpContext.TraceIdentifier;
problemDetails.Extensions["errorCode"] = "validation_error";
return new BadRequestObjectResult(problemDetails);
};
});自定义 ProblemDetails 类型
业务领域错误定义
// 定义业务错误枚举(统一错误码来源)
public static class ErrorCodes
{
// 通用错误 1xxx
public const string ValidationFailed = "1001";
public const string NotFound = "1002";
public const string Unauthorized = "1003";
public const string Forbidden = "1004";
public const string Conflict = "1005";
public const string TooManyRequests = "1006";
// 订单相关 2xxx
public const string OrderNotFound = "2001";
public const string OrderStatusInvalid = "2002";
public const string OrderPaymentFailed = "2003";
// 支付相关 3xxx
public const string PaymentAmountInvalid = "3001";
public const string PaymentTimeout = "3002";
public const string PaymentDuplicate = "3003";
// 库存相关 4xxx
public const string StockInsufficient = "4001";
public const string StockReserved = "4002";
}
// 自定义 ProblemDetails(添加业务错误码和显示消息)
public class BusinessProblemDetails : ProblemDetails
{
public string ErrorCode { get; set; } = string.Empty;
public string? DisplayMessage { get; set; }
// 给前端展示的消息(友好文案)
// Detail 保留给开发者/日志使用(详细描述)
}
// 使用自定义 ProblemDetails
public static class ProblemResults
{
public static BusinessProblemDetails NotFound(
string resource, object? resourceId = null)
{
return new BusinessProblemDetails
{
Type = "https://api.example.com/problems/not-found",
Title = "资源不存在",
Status = StatusCodes.Status404NotFound,
Detail = $"请求的资源 {resource} 不存在",
DisplayMessage = "您请求的内容不存在或已被删除",
ErrorCode = ErrorCodes.NotFound,
Instance = $"/api/{resource}/{resourceId}",
Extensions = { ["resource"] = resource, ["resourceId"] = resourceId }
};
}
public static BusinessProblemDetails Conflict(
string resource, string reason)
{
return new BusinessProblemDetails
{
Type = "https://api.example.com/problems/conflict",
Title = "资源冲突",
Status = StatusCodes.Status409Conflict,
Detail = reason,
DisplayMessage = "操作冲突,请刷新后重试",
ErrorCode = ErrorCodes.Conflict,
Extensions = { ["resource"] = resource }
};
}
public static BusinessProblemDetails RateLimited(int retryAfterSeconds)
{
return new BusinessProblemDetails
{
Type = "https://api.example.com/problems/rate-limited",
Title = "请求过于频繁",
Status = StatusCodes.Status429TooManyRequests,
Detail = $"请求频率超过限制,请在 {retryAfterSeconds} 秒后重试",
DisplayMessage = "操作太频繁了,请稍后再试",
ErrorCode = ErrorCodes.TooManyRequests,
Extensions = { ["retryAfter"] = retryAfterSeconds }
};
}
}全局异常映射扩展
// 扩展异常映射,覆盖更多业务场景
public class EnhancedExceptionHandler : IExceptionHandler
{
private readonly IProblemDetailsService _problemDetailsService;
private readonly ILogger<EnhancedExceptionHandler> _logger;
private readonly IHostEnvironment _env;
public EnhancedExceptionHandler(
IProblemDetailsService problemDetailsService,
ILogger<EnhancedExceptionHandler> logger,
IHostEnvironment env)
{
_problemDetailsService = problemDetailsService;
_logger = logger;
_env = env;
}
public async ValueTask<bool> TryHandleAsync(
HttpContext httpContext, Exception exception, CancellationToken cancellationToken)
{
var traceId = httpContext.TraceIdentifier;
var (statusCode, title, detail, errorCode) = MapException(exception);
_logger.LogError(exception,
"处理请求异常 TraceId={TraceId} Code={Code} Path={Path}",
traceId, errorCode, httpContext.Request.Path);
var problem = new ProblemDetails
{
Type = $"https://api.example.com/problems/{errorCode.ToLowerInvariant()}",
Title = title,
Status = statusCode,
Detail = _env.IsDevelopment() ? exception.ToString() : detail,
Instance = httpContext.Request.Path
};
problem.Extensions["traceId"] = traceId;
problem.Extensions["errorCode"] = errorCode;
problem.Extensions["timestamp"] = DateTimeOffset.UtcNow;
// 附加异常特有信息
switch (exception)
{
case FluentValidation.ValidationException validationEx:
var errors = validationEx.Errors
.GroupBy(e => e.PropertyName)
.ToDictionary(g => g.Key, g => g.Select(e => e.ErrorMessage).ToArray());
problem.Extensions["errors"] = errors;
break;
case TimeoutException:
problem.Extensions["retryAfter"] = 30;
break;
}
httpContext.Response.StatusCode = statusCode;
return await _problemDetailsService.TryWriteAsync(new ProblemDetailsContext
{
HttpContext = httpContext,
ProblemDetails = problem,
Exception = exception
});
}
private static (int Status, string Title, string Detail, string Code) MapException(Exception ex) => ex switch
{
NotFoundDomainException e => (404, "资源不存在", e.Message, ErrorCodes.NotFound),
DomainException e => (e.StatusCode, "业务错误", e.Message, e.Code),
FluentValidation.ValidationException => (400, "参数校验失败", "请检查输入参数", ErrorCodes.ValidationFailed),
UnauthorizedAccessException => (403, "无权访问", "您没有执行此操作的权限", ErrorCodes.Forbidden),
TimeoutException => (504, "请求超时", "上游服务响应超时", ErrorCodes.PaymentTimeout),
OperationCanceledException => (499, "请求已取消", "客户端取消了请求", "1007"),
ArgumentException e => (400, "参数错误", e.Message, ErrorCodes.ValidationFailed),
_ => (500, "服务器内部错误", "服务器发生未处理的异常", "5000")
};
}Minimal API 完整集成
Minimal API 异常处理
// Minimal API 完整的 ProblemDetails 集成
var builder = WebApplication.CreateBuilder(args);
// 注册 ProblemDetails
builder.Services.AddProblemDetails(options =>
{
options.CustomizeProblemDetails = context =>
{
context.ProblemDetails.Extensions["traceId"] =
context.HttpContext.TraceIdentifier;
context.ProblemDetails.Extensions["environment"] =
builder.Environment.EnvironmentName;
};
});
// 注册自定义异常处理器
builder.Services.AddExceptionHandler<EnhancedExceptionHandler>();
// 配置验证响应
builder.Services.Configure<ApiBehaviorOptions>(options =>
{
options.InvalidModelStateResponseFactory = context =>
{
var problem = new ValidationProblemDetails(context.ModelState)
{
Type = "https://api.example.com/problems/validation-error",
Title = "参数校验失败",
Status = 400,
Detail = "请检查输入参数",
Instance = context.HttpContext.Request.Path
};
problem.Extensions["traceId"] = context.HttpContext.TraceIdentifier;
return new BadRequestObjectResult(problem);
};
});
var app = builder.Build();
// 异常处理中间件(必须在最前面)
app.UseExceptionHandler();
app.UseStatusCodePages();
// 自定义状态码页面(把 404 等也转换为 ProblemDetails)
app.UseStatusCodePages(async statusCodeContext =>
{
var response = statusCodeContext.HttpContext.Response;
if (response.StatusCode == 404)
{
await response.WriteAsJsonAsync(new ProblemDetails
{
Type = "https://httpstatuses.com/404",
Title = "Not Found",
Status = 404,
Detail = "请求的资源不存在",
Instance = statusCodeContext.HttpContext.Request.Path,
Extensions = { ["traceId"] = statusCodeContext.HttpContext.TraceIdentifier }
});
}
});
// 业务端点
app.MapGet("/api/orders/{id:int}", async (int id, OrderService service) =>
{
var order = await service.GetByIdAsync(id);
return order is null
? Results.Problem(
statusCode: 404,
title: "订单不存在",
detail: $"订单 {id} 不存在或已被删除",
type: "https://api.example.com/problems/order-not-found",
extensions: new Dictionary<string, object?>
{
["errorCode"] = ErrorCodes.OrderNotFound,
["orderId"] = id
})
: Results.Ok(order);
});
app.Run();统一响应包装
// 统一成功/失败响应格式
public class ApiResponse<T>
{
public bool Success { get; set; }
public T? Data { get; set; }
public ProblemDetails? Error { get; set; }
public string TraceId { get; set; } = string.Empty;
}
// 响应包装中间件
public class ApiResponseWrapperMiddleware
{
private readonly RequestDelegate _next;
public ApiResponseWrapperMiddleware(RequestDelegate next) => _next = next;
public async Task InvokeAsync(HttpContext context)
{
// 如果已经设置了 ProblemDetails,跳过包装
var originalBodyStream = context.Response.Body;
using var responseBody = new MemoryStream();
context.Response.Body = responseBody;
await _next(context);
context.Response.Body = originalBodyStream;
responseBody.Seek(0, SeekOrigin.Begin);
if (context.Response.StatusCode >= 400)
{
// 错误响应已经是 ProblemDetails 格式,透传
await responseBody.CopyToAsync(originalBodyStream);
}
else
{
// 成功响应包装为统一格式
responseBody.Seek(0, SeekOrigin.Begin);
var content = await new StreamReader(responseBody).ReadToEndAsync();
var wrapped = new ApiResponse<object>
{
Success = true,
Data = string.IsNullOrEmpty(content) ? null : JsonSerializer.Deserialize<object>(content),
TraceId = context.TraceIdentifier
};
context.Response.ContentType = "application/json";
await context.Response.WriteAsJsonAsync(wrapped);
}
}
}客户端 SDK 消费
TypeScript 客户端处理
// 前端统一处理 ProblemDetails 的示例
interface ProblemDetails {
type: string;
title: string;
status: number;
detail: string;
instance: string;
traceId: string;
errorCode?: string;
errors?: Record<string, string[]>;
}
async function apiRequest<T>(url: string, options?: RequestInit): Promise<T> {
const response = await fetch(url, options);
if (!response.ok) {
const problem: ProblemDetails = await response.json();
// 根据错误码显示不同提示
switch (problem.errorCode) {
case "1002":
showToast("您请求的内容不存在");
break;
case "1006":
showToast(`操作太频繁,请${problem.extensions?.retryAfter ?? 30}秒后重试`);
break;
default:
showToast(problem.displayMessage || problem.title);
}
// 记录 traceId,方便反馈给客服
console.error(`[${problem.traceId}] ${problem.detail}`);
throw new ApiError(problem);
}
return response.json();
}
class ApiError extends Error {
constructor(public problem: ProblemDetails) {
super(problem.title);
this.name = "ApiError";
}
}C# 客户端处理
// 后端微服务调用时处理 ProblemDetails
public class ProblemDetailsException : Exception
{
public ProblemDetails Problem { get; }
public ProblemDetailsException(ProblemDetails problem)
: base(problem.Title)
{
Problem = problem;
}
}
public static class HttpClientProblemExtensions
{
public static async Task EnsureSuccessProblemDetailsAsync(
this HttpResponseMessage response)
{
if (response.IsSuccessStatusCode) return;
var problem = await response.Content
.ReadFromJsonAsync<ProblemDetails>();
if (problem != null)
{
throw new ProblemDetailsException(problem);
}
response.EnsureSuccessStatusCode();
}
}
// 使用
try
{
var response = await client.GetAsync("/api/orders/123");
await response.EnsureSuccessProblemDetailsAsync();
var order = await response.Content.ReadFromJsonAsync<Order>();
}
catch (ProblemDetailsException ex)
{
_logger.LogError("调用订单服务失败: {TraceId} {Code}",
ex.Problem.Extensions["traceId"],
ex.Problem.Extensions["errorCode"]);
}OpenAPI / Swagger 集成
自定义错误响应 Schema
// 在 Swagger 中注册 ProblemDetails Schema
builder.Services.AddSwaggerGen(options =>
{
options.OperationFilter<ProblemDetailsOperationFilter>();
});
// 为所有端点添加错误响应
public class ProblemDetailsOperationFilter : IOperationFilter
{
public void Apply(OpenApiOperation operation, OperationFilterContext context)
{
// 添加通用错误响应
operation.Responses["400"] = new OpenApiResponse
{
Description = "Bad Request - 参数校验失败",
Content = new Dictionary<string, OpenApiMediaType>
{
["application/problem+json"] = new OpenApiMediaType
{
Schema = context.SchemaGenerator.GenerateSchema(
typeof(ValidationProblemDetails), context.SchemaRepository)
}
}
};
operation.Responses["401"] = new OpenApiResponse
{
Description = "Unauthorized - 未认证",
Content = new Dictionary<string, OpenApiMediaType>
{
["application/problem+json"] = new OpenApiMediaType
{
Schema = context.SchemaGenerator.GenerateSchema(
typeof(ProblemDetails), context.SchemaRepository)
}
}
};
operation.Responses["500"] = new OpenApiResponse
{
Description = "Internal Server Error - 服务器内部错误",
Content = new Dictionary<string, OpenApiMediaType>
{
["application/problem+json"] = new OpenApiMediaType
{
Schema = context.SchemaGenerator.GenerateSchema(
typeof(ProblemDetails), context.SchemaRepository)
}
}
};
}
}生成错误码文档
// 通过反射自动生成错误码文档
public static class ErrorCodeDocumentGenerator
{
public static string GenerateMarkdown()
{
var sb = new StringBuilder();
sb.AppendLine("| 错误码 | 描述 | HTTP 状态码 |");
sb.AppendLine("|--------|------|-------------|");
var fields = typeof(ErrorCodes).GetFields(
BindingFlags.Public | BindingFlags.Static);
foreach (var field in fields)
{
var code = field.GetValue(null)!.ToString()!;
var name = field.Name;
var httpStatus = code switch
{
"1001" => "400",
"1002" => "404",
"1003" => "401",
"1004" => "403",
_ => "500"
};
sb.AppendLine($"| {code} | {name} | {httpStatus} |");
}
return sb.ToString();
}
}优点
缺点
总结
Problem Details 真正解决的是 API 错误语义的统一,而不只是返回一个 JSON。实践里建议把它和全局异常处理、验证失败、日志追踪、业务错误码一起落地,这样客户端、测试、监控和排障才能真正受益。
关键知识点
ProblemDetails适合描述通用错误,ValidationProblemDetails适合字段校验错误。type应表达错误类别,而不是随意留空或全都写成 500。- 业务错误码建议放在
Extensions["errorCode"]中。 - 对外错误信息要克制,对内详细堆栈放日志,不要直接返回客户端。
项目落地视角
- 网关层可基于
status + errorCode做统一提示转换。 - 前端表单页可直接读取
errors字段做字段级高亮。 - 线上排障通过 traceId 快速串联 API 日志与异常日志。
- SDK 可以把 Problem Details 映射成标准异常对象,减少重复判断代码。
常见误区
- 只定义格式,不做异常统一映射,结果仍然到处
return BadRequest("xxx")。 - 把数据库堆栈、连接串等敏感信息直接塞进
detail。 - 所有错误都返回 200 + 业务码,破坏 HTTP 语义。
errorCode、title、type没有统一命名规则,最终失去治理价值。
进阶路线
- 在网关或 BFF 层统一聚合下游 Problem Details。
- 为业务错误码建立文档中心和前后端共享契约。
- 在 OpenAPI / Swagger 中补充标准错误响应模型。
- 为 Problem Details 接入告警、指标和自动归类分析。
适用场景
- 对外 API、前后端分离接口。
- 需要统一错误契约的微服务体系。
- 字段校验较多的管理后台和表单服务。
- 需要 traceId 串联日志与工单排障的系统。
落地建议
- 先统一 400/403/404/409/500 这几个高频错误类别。
- 明确哪些错误对外可见,哪些只记录日志不回显。
- 保持
type、title、errorCode的稳定命名规范。 - 把异常映射、模型验证、手工 Problem 返回方式统一到一个团队模板里。
排错清单
- 检查是否注册了
AddProblemDetails()和异常处理器。 - 检查
UseExceptionHandler()是否在正确位置。 - 检查
ApiController是否启用,验证失败是否自动返回。 - 检查返回的状态码、type、errorCode 是否符合团队规范。
复盘问题
- 客户端能否只通过
status + errorCode就完成分支处理? - 你的错误响应里哪些字段是“给机器看”,哪些字段是“给人看”?
- 线上问题发生后,traceId 是否能真正帮助你快速定位?
- 老接口是否需要逐步迁移到统一 Problem Details 格式?
