NET8 WebAPI 錯誤訊息回應研究

引言

又一個無言以對的問題。若 WebAPI 執行都成功如預期那就好辦了。 實際上會有失敗的狀況,而錯誤訊息回應並沒有標準答案也沒有通同典範。 剛好有機會就研究一下。

論 HTTP Status Code

HTTP Status Code 指向成功的代碼有 200, 204,這 200 是文字、HTML、JSON、FILE 由工程師決定,這在實務上沒有問題,因為已預期成功送回的格式。 HTTP Status Code 指向失敗的代碼有 4XX, 5XX 一大堆。對工程師有意義的只有 400, 422, 500。簡介如下:

HTTP Status Code
論述

200 OK

格式未知,不過可預期實務上沒有問題。

204 NoContent

成功,沒有回傳內容。實務上是有的。

400 Bad Request

格式未知,也不可預期,實務上問題可大了。

401 Unauthorized

未授權。一般指未登入。(不在本文討論)

403 Forbidden

授權不足。一般指已登入但權限不夠不能執行某些功能。(不在本文討論)

404 Not Found

功能不存在。(不在本文討論)

422 Unprocessable Entity / Unprocessable Content

文字描述有二種不過意義是一樣的,就是客戶端給的封包不能處理。 原因當然是封包內容驗證失敗。送回錯誤原由建議用 JSON 送回。

500 Internal Server Error

基本上是系統級錯誤,一般轉成文字再處置。

先下結論

以 Web API 來說, 成功訊息就回傳 200 JSON object。若是下載檔案也是預期之中看是 byte[] 還是 stream 都是預期之中。 另一個成功訊息 204 NoContent 也是預期之中的格式處置上沒有問題。 失敗訊息可能是4xx或5xx。建議直接送回以文字解析就好,不強改 status code 因為可能發生預期之外的系統級例外。進一步的失敗訊息可用 422 送回 JSON 格式的錯誤訊息,比如放表單的 validation message。

回應指令成功指令有2個 Ok(),NoContent(),不過失敗指令有數個:BadRequest()UnprocessableEntity()ValidationProblem()Problem()、throw out exception!!! 。

簡述封包送到前端的解析動作

Ok(JSON | FILE)           => 200 JSON | FILE
NoContent              => empty
BadRequest(TEXT)          => 400 TEXT
UnprocessableEntity(JSON) => 422 JSON --- 建議放 validation message。 
Problem(...)              => 500 TEXT --- 這指令不建議使用。一般是例外無法處置且機率不大,乾脆轉成文字就好。
ValidationProblem(...)    => 400 TEXT --- 這指令不建議使用,因為與 BadRequest 的 status code 突衝(囧)。
throw out exception!!!    => 500 TEXT --- 這不該發生,莫非定律說就是會發生。
其他4xx                    => 4xx TEXT 
其他5xx                    => 5xx TEXT

ValidationProblem(...) 預設回傳 status code 由 400 BadRequest 改成 422 UnprocessableEntity 應該也合理,只是多一些沒有實際意義的描述。

其中 Problem 的訊息規格是有學理規範的。RFC 9110: HTTP Semantics。

https://datatracker.ietf.org/doc/html/rfc9110#section-15.5.1

結論的結論

一般用下面指令與格式送回封包就可以了,暨符合學理又實務。權限相關的 401, 403, 404 等不在本文討論。

成功,有結果 => Ok(JSON | FILE)           => 200 JSON | FILE
成功,無結果 => NoContent              => 204 empty
表單驗證失敗 => UnprocessableEntity(JSON) => 422 JSON --- (option)
錯誤與例外   => BadRequest(TEXT)          => 400 TEXT
系統級例外   => (system throw out)        => 500 TEXT

回傳指令與回應封包紀錄

Ok(JSON) ==> 200; application/json;

return Ok(new { Message = $"你輸入了{num} JSON" });
==>
Status: 200 OK
application/json; charset=utf-8, 34 bytes
{
  "message": "你輸入了200 JSON"
}

Ok(TEXT) ==> 200; text/plain;

return Ok($"你輸入了{num}");

Status: 200 OK
text/plain; charset=utf-8, 15 bytes

你輸入了201

NoContent() ==> 204;

return NoContent();

Status: 204 No Content
0 bytes

BadRequest(JSON)==> 400; application/json;

return BadRequest(new { Message = "模擬錯誤訊息 JSON。" });
  
Status: 400 Bad Request
application/json; charset=utf-8, 40 bytes

{
  "message": "模擬錯誤訊息 JSON。"
}

BadRequest(TEXT)==> 400; text/plain;

return BadRequest("模擬錯誤訊息 TEXT。");

Status: 400 Bad Request
text/plain; charset=utf-8, 26 bytes

模擬錯誤訊息 TEXT。

UnprocessableEntity(JSON)==> 422; application/json;

return UnprocessableEntity(new { Message = "模擬錯誤訊息 JSON。" });

Status: 422 Unprocessable Entity
application/json; charset=utf-8, 40 bytes

{
  "message": "模擬錯誤訊息 JSON。"
}

UnprocessableEntity(TEXT)==> 422; text/plain;

return UnprocessableEntity("模擬錯誤訊息 TEXT。");

Status: 422 Unprocessable Entity
text/plain; charset=utf-8, 26 bytes

模擬錯誤訊息 TEXT。

ValidationProblem(TEXT)==> 400; application/problem+json;

return ValidationProblem("模擬錯誤訊息 TEXT。");

Status: 400 Bad Request
application/problem+json; charset=utf-8, 242 bytes

{
  "type": "https://tools.ietf.org/html/rfc9110#section-15.5.1",
  "title": "One or more validation errors occurred.",
  "status": 400,
  "detail": "模擬錯誤訊息 TEXT。",
  "errors": {},
  "traceId": "00-ee20408ed761a2a36ddb449bb160b5d4-f94a3619b8b73459-00"
}

ValidationProblem(this.ModelState)==>400; application/problem+json;

this.ModelState.AddModelError("某個欄位", "模擬某個欄位錯誤訊息!");
return ValidationProblem(this.ModelState);
		
Status: 400 Bad Request
application/problem+json; charset=utf-8, 254 bytes

{
  "type": "https://tools.ietf.org/html/rfc9110#section-15.5.1",
  "title": "One or more validation errors occurred.",
  "status": 400,
  "errors": {
    "某個欄位": [
      "模擬某個欄位錯誤訊息!"
    ]
  },
  "traceId": "00-5d453b222aaa907c2269b9698f38528d-4539c827740335df-00"
}

Problem(TEXT) ==>500; application/problem+json;

return Problem("模擬錯誤訊息 TEXT。");

Status: 500 Internal Server Error
application/problem+json; charset=utf-8, 239 bytes

{
  "type": "https://tools.ietf.org/html/rfc9110#section-15.6.1",
  "title": "An error occurred while processing your request.",
  "status": 500,
  "detail": "模擬錯誤訊息 TEXT。",
  "traceId": "00-ad743e71650cbbae96626ef2572375cb-a6ea3a8d5f61c3be-00"
}

throw out ==>500; text/plain;

throw new ApplicationException("模擬錯誤訊息 TEXT。");

Status: 500 Internal Server Error
text/plain; charset=utf-8, 2730 bytes

System.ApplicationException: 模擬錯誤訊息 TEXT。
   at Microsoft.AspNetCore.Mvc.Infrastructure.ResourceInvoker.<InvokeFilterPipelineAsync>g__Awaited|20_0(ResourceInvoker invoker, Task lastTask, State next, Scope scope, Object state, Boolean isCompleted)
   at Microsoft.AspNetCore.Mvc.Infrastructure.ResourceInvoker.<InvokeAsync>g__Awaited|17_0(ResourceInvoker invoker, Task task, IDisposable scope)
   at Microsoft.AspNetCore.Mvc.Infrastructure.ResourceInvoker.<InvokeAsync>g__Awaited|17_0(ResourceInvoker invoker, Task task, IDisposable scope)
   at Microsoft.AspNetCore.Authorization.AuthorizationMiddleware.Invoke(HttpContext context)
   at Microsoft.AspNetCore.Authentication.AuthenticationMiddleware.Invoke(HttpContext context)
   at Rin.Middlewares.DiagnosticsMiddleware.InvokeAsync(HttpContext context)
   at Swashbuckle.AspNetCore.SwaggerUI.SwaggerUIMiddleware.Invoke(HttpContext httpContext)
   at Swashbuckle.AspNetCore.Swagger.SwaggerMiddleware.Invoke(HttpContext httpContext, ISwaggerProvider swaggerProvider)
   at Rin.Middlewares.RequestRecorderMiddleware.InvokeAsync(HttpContext context, RinOptions options)
   at Rin.Middlewares.RequestRecorderMiddleware.InvokeAsync(HttpContext context, RinOptions options)
   at Microsoft.AspNetCore.Diagnostics.DeveloperExceptionPageMiddlewareImpl.Invoke(HttpContext context)

HEADERS
=======
Host: localhost:7144
traceparent: 00-b323e54f2f4be7dddfbaab2761d85c1c-f3cb38ac9d6283c4-00
Content-Length: 0


附上測試程式碼

[AllowAnonymous]
[Http G("[action]/{num}")]
public IActionResult FailHandlingLab(int num)
{
  try
  {
    if (num == 400)
      return BadRequest(new { Message = "模擬錯誤訊息 JSON。" });

    if (num == 4002)
      return BadRequest("模擬錯誤訊息 TEXT。");

    if (num == 4003)
      return ValidationProblem("模擬錯誤訊息 TEXT。");

    if (num == 4004)
      return ValidationProblem("模擬錯誤訊息 TEXT。", "我是 instance", 400, "我是 title", "我是 type");

    if (num == 4005)
    {
      this.ModelState.AddModelError("某個欄位", "模擬某個欄位錯誤訊息!");
      return ValidationProblem(this.ModelState);
    }

    if (num == 422)
      return UnprocessableEntity("模擬錯誤訊息 TEXT。");

    if (num == 4222)
      return UnprocessableEntity(new { Message = "模擬錯誤訊息 JSON。" });

    if (num == 500)
      return Problem("模擬錯誤訊息 TEXT。");

    if (num == 600)
      throw new ApplicationException("模擬錯誤訊息 TEXT。");

    if (num == 200)
      return Ok(new { Message = $"你輸入了{num} JSON" });

    if (num == 204)
      return NoContent();

    return Ok($"你輸入了{num}");
  }
  catch
  {
    throw;
  }
}

(EOF)

Last updated