9

解析 WebAPI HTTP 500 回應附帶的 JSON 內容

 1 year ago
source link: https://blog.darkthread.net/blog/read-http500-json/
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

解析 WebAPI HTTP 500 回應附帶的 JSON 內容-黑暗執行緒

前陣子專頁有篇貼文談到 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());
}

Fig1_638376756478706756.png

換言之,在新時代,統一傳回 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。

Fig2_638376756486142422.gif

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


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK