You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

ASP.NET Core 8中[ProducesResponseType(201)]致IProblemDetailsWriter失效问题

问题

我有一个ASP.NET Core Web API控制器动作,返回带自定义类型的201 Created响应,同时配置了自定义异常处理器,用于为400-500错误生成ProblemDetails格式响应。

控制器动作代码如下:

[Authorize]
[HttpPost]
//[ProducesResponseType(typeof(TaskItemCreatedResponse), StatusCodes.Status201Created)]
//[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)]
//[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status401Unauthorized)]
public async Task<IActionResult> CreateTaskItem(
    [FromBody] CreateTaskItemRequest query,
    CancellationToken cancellationToken = default)
{
    var taskItem = await TaskItem.Create(query, usersRepository, cancellationToken);
    await taskItemsRepository.AddTaskItem(taskItem, cancellationToken);
    await unitOfWorkRepository.SaveChangesAsync(cancellationToken);

    return CreatedAtAction(
        nameof(CreateTaskItem), 
        new { id = taskItem.Id }, 
        new TaskItemCreatedResponse(
            taskItem.Id,
            taskItem.Title,
            taskItem.Description,
            taskItem.IsCompleted,
            taskItem.UserId));
}

当取消注释[ProducesResponseType(typeof(TaskItemCreatedResponse), StatusCodes.Status201Created)]这一行时,异常处理器会抛出错误:

Unable to find a registered IProblemDetailsWriter that can write to the given context

但如果为400或401状态码添加ProducesResponseType,或者为201状态码使用不带typeof(TaskItemCreatedResponse)的ProducesResponseType,则一切正常。经观察,只有为2xx状态码指定带类型的ProducesResponseType时才会触发此问题。

如何在保留[ProducesResponseType(typeof(TaskItemCreatedResponse), 201)]的同时,确保自定义ProblemDetails异常处理器能正常处理错误响应?

编辑:自定义异常处理器相关代码如下

CustomExceptionHandler

public class CustomExceptionHandler(
    IProblemDetailsService problemDetailService,
    ILogger<CustomExceptionHandler> logger, 
    IExceptionMapper mapper) 
    : IExceptionHandler
{
    public async ValueTask<bool> TryHandleAsync(
        HttpContext httpContext, 
        Exception exception,
        CancellationToken cancellationToken = default)
    {
        logger.LogError(
            exception, "Exception occurred: {Message}", exception.Message);

        var problemDetails = mapper.MapException(httpContext, exception);

        try
        {
            await problemDetailService.WriteAsync(
            new ProblemDetailsContext
            {
                HttpContext = httpContext,
                Exception = exception,
                ProblemDetails = problemDetails
            });
        }
        catch (Exception e)
        {
            logger.LogError(e.Message);
        }

        return true;
    }
}

IExceptionMapper

public interface IExceptionMapper
{
    ProblemDetails MapException(HttpContext httpContext, Exception exception);
}

ExceptionMapper

public class ExceptionMapper : IExceptionMapper
{
    public ProblemDetails MapException(
        HttpContext httpContext,
        Exception exception)
    {
        int statusCode;
        string title;
        var errors = new Dictionary<string, string[]>();

        switch(exception)
        {
            case ValidationException e:
                statusCode = 400;
                title = "Validation failed";
                errors = e.Errors
                    .GroupBy(e => e.PropertyName)
                    .ToDictionary(g => g.Key,
                    g => g.Select(e => e.ErrorMessage).ToArray());
                break;
            case BadRequestException:
                statusCode = 400;
                title = "Error caused by request";
                break;
            case UnauthorizedException:
                statusCode = 401;
                title = "Unauthorized access";
                break;
            case NotFoundException:
                statusCode = 404;
                title = "Resource cannot be found";
                break;
            default:
                statusCode = 500;
                title = "Internal server problem";
                break;
        }

        httpContext.Response.StatusCode = statusCode;

        var problemDetails = new ProblemDetails
        {
            Type = exception.GetType().Name,
            Status = statusCode,
            Title = title
        };

        if (errors.Count() == 0)
            problemDetails.Detail = exception.Message;
        else
            problemDetails.Extensions.Add("errors", errors);

        return problemDetails;
    }
}

组件注册代码

public static class ExceptionHandlingMiddlewareRegistration
{
    public static void RegisterExceptionHandling(this IServiceCollection serviceCollection)
    {
        serviceCollection.AddExceptionHandler<CustomExceptionHandler>();
        serviceCollection.AddSingleton<IExceptionMapper, ExceptionMapper>();
        serviceCollection.AddProblemDetails(options =>
        {
            options.CustomizeProblemDetails = context =>
            {
                context.ProblemDetails.Instance =
                    $"{context.HttpContext.Request.Method} {context.HttpContext.Request.Path}";

                context.ProblemDetails.Extensions.TryAdd("requestId", context.HttpContext.TraceIdentifier);

                Activity? activity = context.HttpContext.Features.Get<IHttpActivityFeature>()?.Activity;
                context.ProblemDetails.Extensions.TryAdd("traceId", activity?.Id);

                context.ProblemDetails.Extensions.Add(
                    "stackTrace",
                    context.Exception.StackTrace
                        .Split(
                            ["\r\n", "\n"],
                            StringSplitOptions.TrimEntries));
            };
        });
    }
}
解决方案

这个问题的核心是:当你为2xx成功响应指定了返回类型后,ASP.NET Core会将该动作的默认响应格式化器限制为只能处理TaskItemCreatedResponse类型,而错误场景下需要返回的ProblemDetails类型不在允许范围内,导致找不到合适的IProblemDetailsWriter。

可以通过以下三种方式解决:


方法一:显式为错误状态码指定ProducesResponseType并声明支持ProblemDetails

在控制器动作上,除了保留201的类型声明,还要为所有可能的错误状态码添加ProducesResponseType,明确指定返回类型为ProblemDetails,同时通过[Produces]属性声明API支持的响应格式(比如JSON),确保格式化器能同时处理成功和错误类型:

[Authorize]
[HttpPost]
[Produces("application/json")] // 显式声明支持的响应格式
[ProducesResponseType(typeof(TaskItemCreatedResponse), StatusCodes.Status201Created)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status401Unauthorized)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status500InternalServerError)]
public async Task<IActionResult> CreateTaskItem(...)
{
    // 原有代码
}

这样ASP.NET Core会知道该动作既可以返回TaskItemCreatedResponse,也可以返回ProblemDetails,不会限制格式化器的范围。


方法二:配置AddProblemDetails时强制注册默认写入器

当动作指定了特定返回类型后,默认的ProblemDetailsWriter可能未被选中。可以在注册AddProblemDetails时,显式注册内置的默认写入器,确保它能处理所有错误场景:

修改ExceptionHandlingMiddlewareRegistration中的代码:

serviceCollection.AddProblemDetails(options =>
{
    // 原有自定义逻辑保持不变
    options.CustomizeProblemDetails = context =>
    {
        context.ProblemDetails.Instance =
            $"{context.HttpContext.Request.Method} {context.HttpContext.Request.Path}";

        context.ProblemDetails.Extensions.TryAdd("requestId", context.HttpContext.TraceIdentifier);

        Activity? activity = context.HttpContext.Features.Get<IHttpActivityFeature>()?.Activity;
        context.ProblemDetails.Extensions.TryAdd("traceId", activity?.Id);

        if (context.Exception != null)
        {
            context.ProblemDetails.Extensions.Add(
                "stackTrace",
                context.Exception.StackTrace
                    .Split(
                        ["\r\n", "\n"],
                        StringSplitOptions.TrimEntries));
        }
    };
})
// 显式注册默认的ProblemDetails写入器
.AddSingleton<IProblemDetailsWriter, DefaultProblemDetailsWriter>();

这个写入器是ASP.NET Core内置的,能处理标准的ProblemDetails类型,确保异常处理器在任何场景下都能找到可用的写入器。


方法三:在异常处理器中手动写入响应(绕过IProblemDetailsService)

如果上述方法都不生效,可以直接在CustomExceptionHandler中手动序列化ProblemDetails并写入响应,避免依赖IProblemDetailsService的自动匹配:

修改CustomExceptionHandler的TryHandleAsync方法:

public async ValueTask<bool> TryHandleAsync(
    HttpContext httpContext, 
    Exception exception,
    CancellationToken cancellationToken = default)
{
    logger.LogError(exception, "Exception occurred: {Message}", exception.Message);

    var problemDetails = mapper.MapException(httpContext, exception);

    try
    {
        // 手动序列化ProblemDetails并写入响应
        httpContext.Response.ContentType = "application/json";
        await httpContext.Response.WriteAsJsonAsync(problemDetails, cancellationToken);
    }
    catch (Exception e)
    {
        logger.LogError(e.Message);
    }

    return true;
}

这种方式完全绕过了IProblemDetailsService的匹配逻辑,直接控制响应的生成,兼容性最好,但需要自己处理序列化细节。


内容的提问来源于stack exchange,提问作者Ucrainex

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.06.11 23:44:51