ASP.NET Core PUT APIでNoContent()がScalarの「response with null body status cannot have body」エラーになる原因と対処法

ASP.NET Core で PUT API を実装していて、NoContent() を返しているだけなのに、ドキュメント&テストツールの Scalar から呼び出すと「response with null body status cannot have body」というエラーが出る――そんな現象で手が止まっていませんか?この記事では原因の仕組みと実践的な対処手順を、コード例付きで詳しく解説します。

目次

前提環境とエラーの全体像

前提環境

まずはよくある構成を整理しておきます。以下のような環境を想定します。

項目内容
IDEVisual Studio 2022
言語C# 9
フレームワークASP.NET Core(Web API)
エンドポイント[HttpPut] による更新 API
レスポンス更新完了後に return NoContent();(HTTP 204)
クライアントScalar(OpenAPI ベースのドキュメント&テストツール)

発生しているエラー

上記の構成で PUT API を Scalar から呼び出すと、ブラウザのコンソールや Scalar の UI 上で次のようなエラーが表示されるケースがあります。

failed to construct 'response'
response with null body status cannot have body

このエラーのポイントは、

  • 「null body status(=ボディを持たないべきステータス)」
  • 「にもかかわらず body がある」

と Scalar 側が判断している、という点です。HTTP 204 は仕様上「ボディを持ってはいけない」ステータスなので、スキーマや実際のレスポンスに矛盾があると、Scalar などのツールが厳密にチェックしてエラー扱いにすることがあります。

なぜ PUT + 204 で問題が出やすいのか

REST API では、リソース更新を行う PUT メソッドのレスポンスとして 204 No Content を返すのはよくある設計です。しかし、以下のような条件が揃うと Scalar で問題が顕在化します。

  • OpenAPI/Swagger のスキーマ上は 200 OK しか宣言していない
  • 実際の API 実装では NoContent() を返して 204 を返却している
  • さらにミドルウェアや例外ハンドラなどがレスポンスボディを書き込んでしまう

このような状況だと、Scalar から見ると

  • 「スキーマには 200 しか書いてないのに 204 が返ってきた」
  • 「204 なのにレスポンスボディが付いているように見える」

と解釈され、response with null body status cannot have body というエラーとして表面化します。

以降では、原因を分解しながら解決策を順に見ていきます。

HTTP 204 No Content と ASP.NET Core の挙動

HTTP 204 No Content の仕様

HTTP 204 は「リクエストは成功したが、返すべきコンテンツ(ボディ)は存在しない」ことを意味するステータスコードです。特徴を整理すると次のようになります。

項目204 No Content の扱い
レスポンスボディ仕様上、「あってはならない」とされている
Content-Length0 もしくはヘッダー自体なし(ボディ長 0 と解釈される)
Content-Type省略されることが多い(ボディが無いため)
代表的な用途PUT / PATCH / DELETE 後の「成功したが再取得不要」なケース

クライアントやツールは、204 を受け取ったらボディを読みに行かない ことを前提に実装されています。そのため、204 にもかかわらずボディが存在しているように見えると「仕様違反」と判断され、今回のようなエラーを吐くことがあります。

ASP.NET Core の NoContent() がやっていること

ASP.NET Core のコントローラでよく見る NoContent() は、内部的には StatusCodeResult(204) を返すショートカットです。典型的なコードは次のようになります。

[ApiController]
[Route("api/[controller]")]
public class UsersController : ControllerBase
{
    [HttpPut("{id}")]
    public IActionResult UpdateUser(int id, [FromBody] UpdateUserRequest request)
    {
        // ここで更新処理

        return NoContent(); // 204 No Content を返す
    }
}

このとき、ASP.NET Core 自体はボディを書きません。したがって、素の状態であれば 204 かつボディなしで返ってくるはずです。

ところが、

  • カスタムミドルウェア
  • グローバル例外ハンドラ
  • レスポンスロギング用のミドルウェア

などが途中で HTML エラーページや JSON メッセージを差し込んだりすると、「204 なのにボディが存在する」状態になってしまうことがあります。これが Scalar が嫌うパターンです。

解決策 1: OpenAPI / Swagger に 204 応答を明示する

なぜスキーマに 204 を書く必要があるのか

Scalar は OpenAPI(swagger.yaml / openapi.json)を読み込み、そのスキーマを 「正解のリスト」 として扱います。つまり、

  • スキーマに書いてあるステータスコード → 許可されたレスポンス
  • スキーマに書いていないステータスコード → 想定外=エラー扱い

という非常に真面目な態度を取ります。

ところが実装側が 204 を返しているのに、スキーマでは 200 しか宣言していないケースはよくあります。その場合、Scalar は「スキーマには 200 しか無いのに 204 が返ってきた」と判断し、「レスポンスの解釈に失敗した」として今回のようなエラーを出します。

コントローラで 204 を明示する(C# 属性)

ASP.NET Core のコントローラベース Web API では、[ProducesResponseType] 属性を使って、エンドポイントの返却ステータスを明示できます。PUT API に 204 を足したい場合は次のようにします。

[ApiController]
[Route("api/[controller]")]
public class UsersController : ControllerBase
{
    [HttpPut("{id}")]
    [ProducesResponseType(StatusCodes.Status204NoContent)]
    [ProducesResponseType(StatusCodes.Status400BadRequest)]
    [ProducesResponseType(StatusCodes.Status404NotFound)]
    public IActionResult UpdateUser(int id, [FromBody] UpdateUserRequest request)
    {
        if (!ModelState.IsValid)
        {
            return BadRequest(ModelState);
        }

        var user = _userService.Find(id);
        if (user is null)
        {
            return NotFound();
        }

        // 更新処理
        _userService.Update(id, request);

        return NoContent();
    }
}

このように書くと、Swashbuckle などが生成する OpenAPI には

  • 204 No Content
  • 400 Bad Request
  • 404 Not Found

が responses として明示されます。Scalar もそれを見て「PUT の成功は 204 ね」と理解してくれるため、204 自体はエラー扱いされなくなります。

シナリオOpenAPI に 204 を書かないと…OpenAPI に 204 を書くと…
Scalar から PUT をテスト204 が「想定外ステータス」と判断されエラー204 が「成功パターン」として正しく扱われる
他ツール(コード生成など)200 しか想定しないクライアントコードが生成される204 に対応したクライアントコードが生成される

minimal API で 204 を明示する

.NET 6 以降の minimal API でも同様に、.Produces() などで 204 を宣言することができます。

var app = WebApplication.CreateBuilder(args).Build();

app.MapPut("/api/users/{id:int}", async (int id, UpdateUserRequest request, IUserService userService) =>
{
    var user = await userService.FindAsync(id);
    if (user is null)
    {
        return Results.NotFound();
    }

    await userService.UpdateAsync(id, request);
    return Results.NoContent();
})
.Produces(StatusCodes.Status204NoContent)
.Produces(StatusCodes.Status404NotFound)
.Produces(StatusCodes.Status400BadRequest);

app.Run();

minimal API では .Produces() が OpenAPI の responses セクションに対応します。Scalar はここを見て 204 を「正しいレスポンス」として扱うようになります。

OpenAPI 定義ファイルを直接編集する場合

なんらかの理由で swagger.yaml / openapi.json を手書きしている場合は、次のように 204 を responses に追加します。

paths:
  /api/users/{id}:
    put:
      summary: Update user
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: integer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/UpdateUserRequest'
      responses:
        '204':
          description: No Content
        '400':
          description: Bad Request
        '404':
          description: Not Found

Scalar が参照している OpenAPI がこの形になっているかどうか、一度確認してみてください。

解決策 2: サーバーが本当に「空ボディ」で返しているか確認する

curl で 204 の中身を確認する

次に、ASP.NET Core が返しているレスポンスが本当に「204 かつボディなし」なのかを確認します。もっとも手軽なのは curl -v です。

curl -v -X PUT "https://localhost:5001/api/users/1" ^
  -H "Content-Type: application/json" ^
  -d "{ \"name\": \"Alice\" }"

良いパターンのレスポンスヘッダー例は次のようになります。

< HTTP/1.1 204 No Content
< Date: Mon, 01 Jan 2025 00:00:00 GMT
< Server: Kestrel
< Content-Length: 0

または Content-Length が省略されていても構いませんが、そこに HTML や JSON が続いていないこと が重要です。

もし次のようなレスポンスになっていたら要注意です。

< HTTP/1.1 204 No Content
< Content-Type: text/html; charset=utf-8
< Transfer-Encoding: chunked

<!DOCTYPE html>
<html>
  <head>...(エラーページ)...

これは「204 なのに HTML が付いている」状態なので、Scalar が「null body status なのに body がある」と怒っている可能性が高くなります。

Postman など GUI ツールでの確認ポイント

Postman などを使う場合は、レスポンスウィンドウで以下をチェックします。

確認項目OK な状態NG な状態
Status204 No Content500 Internal Server Error など
Headers: Content-Length0 または存在しない0 より大きい値
Body タブ何も表示されない(空)HTML や JSON が表示される

ここで Body に何か表示されていたら、「どこかでボディを書いている」ことが確定です。

ボディが付いてしまうよくある原因

204 にボディが付く原因として、次のようなパターンが多いです。

  • グローバル例外ハンドラが HTML エラーページを返している
  • カスタムミドルウェアがエラー時に JSON エラーメッセージを返し、ステータスコードだけ 204 のままになっている
  • レスポンス圧縮やロギングのミドルウェアが不適切にボディを書き換えている

例外ハンドラの典型的な落とし穴は、次のように Response.StatusCode を 204 のまま残してしまうケースです。

app.Use(async (context, next) =>
{
    try
    {
        await next();
    }
    catch (Exception ex)
    {
        context.Response.ContentType = "application/json";
        // 本来は 500 等をセットすべきだが、うっかり何もしない
        // context.Response.StatusCode = (int)HttpStatusCode.InternalServerError;

        await context.Response.WriteAsync("{\"message\":\"error\"}");
    }
});

このようなコードがあると、アクションが NoContent() を返しても、例外発生時には「StatusCode: 204 のまま JSON ボディ付き」という矛盾したレスポンスになってしまいます。

解決策 3: ログとデバッガでレスポンスの流れを追う

デバッグ時のチェックポイント

Visual Studio でデバッグ実行し、PUT アクションの return NoContent(); 直前にブレークポイントを置きます。そのタイミングで次のプロパティをウォッチしてみてください。

  • HttpContext.Response.StatusCode
  • HttpContext.Response.HasStarted
  • HttpContext.Response.ContentLength

正常なケースでは次のようになっているはずです。

プロパティ期待値
StatusCodeまだ 200 のままでも良い(NoContent() で 204 に書き換えられる)
HasStartedfalse(レスポンスはまだ書き出されていない)
ContentLengthnull または 0

もし HasStarted が true になっている場合は、どこかのミドルウェアやフィルタがすでにレスポンスを書き始めている ことを意味します。その箇所を特定するためには、ミドルウェアチェーンを一つずつコメントアウトする、ロギングを仕込むなどの方法が有効です。

ASP.NET Core のログを詳しく出す

レスポンスまわりの問題を追うには、ログレベルを一時的に上げてみるのも有効です。appsettings.Development.json などで次のように設定してみます。

{
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft.AspNetCore": "Debug"
    }
  }
}

この状態でアプリを起動すると、Kestrel やミドルウェアが出すログが詳細に出るようになり、「どのタイミングでレスポンスが書き始められたか」が追いやすくなります。

解決策 4: Scalar 側のスキーマ/キャッシュを更新する

OpenAPI スキーマの再生成

[ProducesResponseType] や .Produces() を追加しても、Scalar が古いスキーマを握ったままだと、いつまでたっても 204 を「知らない子」として扱い続けます。

Swashbuckle を利用している場合は、起動時に自動的に /swagger/v1/swagger.json が生成されますが、

  • 環境変数や設定でエンドポイントの URL が変わっている
  • 複数バージョンのスキーマを同時に公開している

といったケースでは、Scalar が別バージョンのスキーマを読みに行っている可能性もあります。以下のような点を確認してみてください。

  • Scalar の設定画面で指定している OpenAPI の URL が、実際に更新されたものか
  • ブラウザで直接その URL(例: https://localhost:5001/swagger/v1/swagger.json)を開き、"204" が responses に含まれているか

ブラウザキャッシュや Scalar の内部キャッシュ

Scalar をブラウザ版で利用している場合、ブラウザキャッシュに古いスキーマが残っていることもあります。次のような対応を試してみましょう。

  • ブラウザのシークレットウィンドウで Scalar を開き直す
  • Scalar の画面上に「スキーマ再読み込み」ボタンがあれば実行する
  • OpenAPI の URL を一度別のものに切り替えてから戻す

これらの操作でキャッシュがクリアされ、最新の 204 設定が反映されるようになります。

REST 設計の観点から見た 204 と 200 の使い分け

クライアントが 204 を扱えない場合の妥協案

どうしてもクライアント側(あるいは特定ツール)が 204 をうまく扱えない場合、設計としてあえて 200 OK +空 JSON を返すという選択肢もあります。

[HttpPut("{id}")]
[ProducesResponseType(StatusCodes.Status200OK)]
public IActionResult UpdateUser(int id, [FromBody] UpdateUserRequest request)
{
    // 更新処理

    // 空 JSON を返す
    return Ok(new { });
}

この方法には次のようなメリットとデメリットがあります。

観点204 No Content200 OK + 空 JSON
REST / HTTP の「教科書的」な正しさ高い(ボディなしで成功を表現)やや落ちる(意味のないボディを付けている)
ツール/クライアントの互換性ツールによっては扱いが厳格でエラーになるほぼ確実に問題なく扱える
ネットワーク帯域最小(ヘッダーのみ)ごくわずかに増えるが実用上無視できることが多い

可能であれば 204 を採用したままスキーマと実装を整えるのがベストですが、既に多数のクライアントが存在している場合などは、200 + 空 JSON を使うことで現実的な落とし所を作ることもあります。

実装例:PUT API を最初から「204 対応」で設計する

コントローラベースの完全な例

ここまでのポイントを踏まえた、シンプルかつ「Scalar フレンドリー」な PUT API の例を示します。

[ApiController]
[Route("api/[controller]")]
public class UsersController : ControllerBase
{
    private readonly IUserService _userService;
    private readonly ILogger<UsersController> _logger;

    public UsersController(IUserService userService, ILogger<UsersController> logger)
    {
        _userService = userService;
        _logger = logger;
    }

    /// <summary>
    /// ユーザー情報を更新する
    /// </summary>
    [HttpPut("{id}")]
    [ProducesResponseType(StatusCodes.Status204NoContent)]
    [ProducesResponseType(StatusCodes.Status400BadRequest)]
    [ProducesResponseType(StatusCodes.Status404NotFound)]
    public IActionResult UpdateUser(int id, [FromBody] UpdateUserRequest request)
    {
        if (!ModelState.IsValid)
        {
            _logger.LogWarning("Invalid model state for user {UserId}", id);
            return BadRequest(ModelState);
        }

        var user = _userService.Find(id);
        if (user is null)
        {
            _logger.LogInformation("User {UserId} not found", id);
            return NotFound();
        }

        _userService.Update(id, request);
        _logger.LogInformation("User {UserId} updated successfully", id);

        // 204 を明示して返す
        return NoContent();
    }
}

ポイントをまとめると以下の通りです。

  • [ProducesResponseType] で 204 / 400 / 404 をスキーマに明示
  • 例外はグローバルハンドラで 500 に統一するなどし、「204 なのにエラー本文付き」を避ける
  • ロギングを入れておくことで、トラブル時に原因特定しやすくしておく

minimal API の完全な例

minimal API で同等のことを行う場合の例です。

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
builder.Services.AddScoped<IUserService, UserService>();

var app = builder.Build();

if (app.Environment.IsDevelopment())
{
    app.UseSwagger();
    app.UseSwaggerUI();
}

app.MapPut("/api/users/{id:int}", async (
    int id,
    UpdateUserRequest request,
    IUserService userService,
    ILogger<Program> logger) =>
{
    if (string.IsNullOrWhiteSpace(request.Name))
    {
        return Results.BadRequest(new { message = "Name is required." });
    }

    var user = await userService.FindAsync(id);
    if (user is null)
    {
        return Results.NotFound();
    }

    await userService.UpdateAsync(id, request);
    logger.LogInformation("User {UserId} updated successfully", id);

    return Results.NoContent(); // 204
})
.Produces(StatusCodes.Status204NoContent)
.Produces(StatusCodes.Status400BadRequest)
.Produces(StatusCodes.Status404NotFound)
.WithName("UpdateUser")
.WithTags("Users");

app.Run();

このように、minimal API でも .Produces(StatusCodes.Status204NoContent) を付けておくことで、OpenAPI に 204 が正しく書き出され、Scalar などからも問題なく扱えるようになります。

最終確認用チェックリスト

最後に、PUT API が NoContent() を返したときに Scalar でエラーになる状況を解消するためのチェックリストをまとめます。

チェック項目確認内容
OpenAPI に 204 が書かれているか[ProducesResponseType(StatusCodes.Status204NoContent)] または .Produces(StatusCodes.Status204NoContent) を追加したか
curl でレスポンスを確認したか204 で返っており、レスポンスボディが完全に空か、Content-Length: 0 になっているか
例外ハンドラの挙動例外発生時に 204 のまま HTML や JSON を返していないか
ミドルウェア/フィルタレスポンスを書き換えるミドルウェアが 204 にボディを追加していないか
Scalar のスキーマ最新の OpenAPI を読み込ませ、ブラウザキャッシュもクリアしたか
やむを得ない場合の代替案どうしても 204 が扱えないクライアントには 200 OK + 空 JSON を返す設計に切り替えるか検討したか

まとめ

PUT API が NoContent() を返しているだけなのに、Scalar 上で

response with null body status cannot have body

と怒られてしまう場合、その原因は大きく分けて次の 2 つです。

  • OpenAPI / Swagger に 204 応答が定義されていない(スキーマの問題)
  • 実際のレスポンスが 204 なのにボディを持っている(実装・ミドルウェアの問題)

この記事で紹介したように、

  • [ProducesResponseType(StatusCodes.Status204NoContent)] や .Produces(StatusCodes.Status204NoContent) を追加してスキーマを整える
  • curl や Postman で 204 レスポンスが本当に空かどうか確認する
  • 例外ハンドラやミドルウェアが 204 にボディを付けていないかログ・デバッガで追う
  • Scalar 側のスキーマキャッシュを更新する

といったステップを踏めば、多くの場合はすっきり解決できます。

REST の慣例的には、PUT の成功レスポンスとして 204 を返すのはとても自然な設計です。今回のようなツールとの相性問題で 204 を諦めてしまう前に、まずはスキーマと実装の整合性をきちんと確認し、HTTP 仕様に沿ったきれいな API を維持していきましょう。

この記事を書いた人

実務の現場で詰まりがちなポイントを地図にするITブログ「IT trip」を運営。Windows/Office(Teams・Excel)からSQL、サーバ運用、ガジェットまで、再現性のある手順と“なぜそうなるか”を丁寧に解説します。読んだらすぐ試せること、そして迷った人の次の一歩が見えることを大切にしています。

コメント

コメントする

目次