前陣子專頁有篇貼文談到 WebAPI 出錯時,是否必須必須透過 HTTP Status 反映執行結果,例如:找不到時吐 404、系統出錯時回應 500?,得到不少回響,我也獲了新體悟。

我一直認為 WebAPI 是種 Contract,服務端與客戶端約定好,雙方都覺得 OK 就好。即便執行結果出錯,統一用 HTTP 200 回傳執行失敗狀態及錯誤訊息沒什麼不對(註:錯誤訊息讓使用者理解狀況或當成回報客服附加資訊即可,應避免揭露系統內部資訊),呼叫端可用一致邏輯處理成功及失敗呼叫結果,挺好的。

之所以會覺得統一傳 200 省事,多少源於 .NET Framework WebClient 元件處理 HTTP 500 的行為。以 WebClient 為例,HTTP 40x/50x 會觸發例外,需要透過 catch 從 WebException.Exception.Response 讀取詳細回應內容,例如:(以下使用 PowerShell 為例,背後是 .NET WebClient 元件)

try {
    Invoke-WebRequest -Uri http://localhost:5203/Http500wJson -Method Post
} 
catch [System.Net.WebException] {
    $resp = $_.Exception.Response
    [int]$resp.StatusCode
    $resp.StatusDescription
    $sr = [IO.StreamReader]::new($resp.GetResponseStream())
    $sr.ReadToEnd()
}

基於這點,我習慣不管成功失敗統一回傳 ApiResult 物件,如該文所說,如此呼叫端可省下另寫 try / catch 處理 HTTP 500 的工夫。

至於使用 HTTP Status Code 302/401/404/500 傳遞狀態,以 .NET WebClient.UploadData() 呼叫時將被視為 Exception,需要 try ... catch 攔截,會增加些許困援。

不過,時代在改變,WebClient 的接任者 - HttpClient,接收 HTTP 40X/500 時不再拋出例外,而是針對需要確認 HTTP 200 的情境提供 IsSuccessStatusCodeEnsureSuccessStatus(),由此可知,非 HTTP 200 回應附帶文字訊息為 JSON 已屬常態,HttpClient 才會做出如此調整。而要讀取 HTTP 500 附帶的 JSON,用 HttpClient 寫起來自然多了:

async Task Test() 
{
	var http = new System.Net.Http.HttpClient();
	var resp = await http.PostAsync("http://localhost:5203/http500wjson", 
		new StringContent(string.Empty, Encoding.UTF8, "application/json"));
	Console.WriteLine(resp.IsSuccessStatusCode);
	Console.WriteLine((int)resp.StatusCode);
	Console.WriteLine(resp.Content.Headers.ContentType.MediaType);
	Console.WriteLine(await resp.Content.ReadAsStringAsync());
}

換言之,在新時代,統一傳回 HTTP 200 的優勢不復存在,傳 500 並不會比較麻煩。好,二者回到平等線,那傳 500 又有什麼優點?

在 FB 貼文討論裡,讀者 Kehao Chen、LienFa Huang 不約而同給了該傳 500 的超級好理由(感謝!)。DevOps 時代講究 Observability (可觀測性),會使用 Prometheus 之類的統一監控平台蒐集系統營運數據,統整到 Grafana 儀錶板方便即時監看,必要時還能主動發送告警通知 SRE,這已成當今系統管理主流。若 WebAPI 錯誤是種警訊需要被觀注,那麼回傳 200 與 500 意義大不相同。回傳 500 可反應在統一監控平台的數據上,還能觸發告警通知人員處理;回傳 200 的話,系統必須自己負起錯誤統計及通報的責任。

從以上角度,回傳 500 的好處不言而喻,這個主張我完全買單。未來設計 WebAPI,我應會選擇在錯誤時改傳 HTTP 500。

最後,用 JavaScript 讀取 HTTP 500 回應 JSON 內容的小練習結束這回合。

寫個簡單 ASP.NET Minimal API,MapPost("/throw") 模擬未處理例外回應、MapPost("/http500wJson") 回傳含 JSON 內容的 HTTP 500 回應;MapPost("/http200") 則是正常回應做為對照:

using System.Text.Json;

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

app.UseDefaultFiles();
app.UseFileServer();

app.MapPost("/throw", () =>
{
    throw new Exception("Exception from /throw");
});

app.MapPost("/http500wJson", (HttpContext context) =>
{
    var resp = context.Response;
    resp.StatusCode = 500;
    resp.ContentType = "application/json";
    var result = new {
        errCode = 9527,
        message = "Exception from /http500wJson"
    };
    return resp.WriteAsync(JsonSerializer.Serialize(result));
});

app.MapPost("/http200", (HttpContext context) =>
{
    var resp = context.Response;
    resp.StatusCode = 200;
    resp.ContentType = "application/json";
    var result = new {
        succ = false,
        errCode = 9527,
        message = "Return error via HTTP 200"
    };
    return resp.WriteAsync(JsonSerializer.Serialize(result));
});

app.Run();

寫個簡單網頁,分別測試用 jQuery.ajax()、XHR、Fetch 呼叫 /throw、/http200 及 /http500wJson。

<!DOCTYPE html>
<html>

<head>
    <meta charset="utf-8" />
    <title>WebAPI 錯誤回傳</title>
    <script src="https://code.jquery.com/jquery-3.3.1.min.js"></script>
</head>

<body>
    <div>
        <label>
            <input type="radio" name="url" value="/throw" checked> throw
        </label>
        <label>
            <input type="radio" name="url" value="/http200"> HTTP 200
        </label>
        <label>
            <input type="radio" name="url" value="/http500wJson"> HTTP 500 with JSON
        </label>
    </div>
    <div>
        <button onclick="testJQuery()">jQuery</button>
        <button onclick="testXhr()">XHR</button>
        <button onclick="testFetch()">Fetch</button>
    </div>
    <dl>
        <dt>Status Code</dt>
        <dd id="statusCode"></dd>
        <dt>Status Text</dt>
        <dd id="statusText"></dd>
        <dt>Response Body</dt>
        <dd id="response"></dd>
    </dl>
    <script>
        function getUrl() {
            display('', 'Sending...', '')
            return document.querySelector('input[name="url"]:checked').value;
        }
        function display(statusCode, statusText, response) {
            document.getElementById('statusCode').innerText = statusCode;
            document.getElementById('statusText').innerText = statusText;
            document.getElementById('response').innerText = response;
        }
        function testJQuery() {
            $.ajax({
                url: getUrl(),
                method: 'POST',
                success: function (data) {
                    display('200', 'OK', JSON.stringify(data));
                },
                error: function (jqXHR, textStatus, errorThrown) {
                    display(jqXHR.status, jqXHR.statusText, jqXHR.responseText);
                }
            });
        }
        function testXhr() {
            var url = getUrl();
            var xhr = new XMLHttpRequest();
            xhr.open('POST', url);
            xhr.onload = function () {
                display(xhr.status, xhr.statusText, xhr.responseText);                
            };
            xhr.send();
        }

        function testFetch() {
            fetch(getUrl(), { method: 'POST' })
                .then(async (response) => {
                    if (response.ok) {
                        display(response.status, response.statusText, JSON.stringify(await response.json()));
                    }
                    else
                        throw response;
                })
                .catch(async (response) => {
                    display(response.status, response.statusText, await response.text());
                });
        }
    </script>
</body>

</html>

實測 OK。

最近常常發現,有些用了很久的老觀念,此刻卻與主流相悖。發現苗頭不對,便該檢視當初考量的前題是否改變,用了十幾年驗證過千百回的老觀念,也會有該拋棄的一天。

Talks about why we should use HTTP 500 instaed of HTTP 200 when WebApi error and how to read JSON data from HTTP 500 response body in .NET and JavaScript.


Comments

# by 黑迷

黑大,有興趣可再探討「Request找無資料時,API究竟該回傳 200 或 404」 這事前陣子也曾掀起大戰 XDD 很好奇黑大會有怎樣的想法? (小弟淺見,若查詢單筆找無資料應回應 404,查詢清單找無資料則回應 200,而無論 404 或 200 都應在Body給足訊息)

# by Jeffrey

to 黑迷,這個議題在 FB 貼文也有討論到,我的看法如下: 每個架構決策必有得有失,REST 基本教義派追求應用程式"必須"與 HTTP 協定緊密融合,主張不符合 HATEOAS 約束就不是 REST ,以我的觀點是過了頭(應跟 REST 之父 Roy Fielding 也是 HTTP 規範主要作者有關吧),有失理性客觀。我不是那麼認同 REST (詳情可見 Web API 是否一定要 RESTful? https://blog.darkthread.net/blog/is-restful-required/ 一文 ),但 REST 是當今主流,向它靠攏可享受眾多現成資源,利多於弊,我願意改採 REST Controller。傳 500 可提高 Observability,我認同,覺得改用有好處。 我自己覺得要求資源跟 URI 百分之百對映這點帶來的限制讓開發變複雜,好處沒有抵銷其成本,故設計 API 不會特別要求,既然沒有落實 URI 與資源完整對映,DB 查不到東西回傳 404 這點便失去意義,對我來說是不值得遵守的 REST 規則,寧可讓它回歸外行人也懂的 404 意義,404 是程式網址錯了,跟查詢參數沒關係。 以上淺見。

# by Jovi

我遇到一個狀況,信用卡回CALL NotifyURL,大部分都正常執行,可是偶爾沒有返回200,讓信用卡公司一直重複CALL,可是程式都正常執行也都有LOG,信用卡公司回復第二三次CALL的時間隔了15秒,5秒,所以是不是因為偶爾程式執行太久導致的結果?

# by Jeffrey

to Jovi,不確定「沒有返回200」的定義,指久等不到結果 Timeout 還是傳回非 200 狀態碼?

Post a comment