FHIR 伺服器的處理錯誤
火線超人可能同時查好幾家醫院的資料。
它的規格裡有一條寫得很死。每一台伺服器的查詢都有各自的逾時上限,預設五秒。一台逾時或出錯,絕對不能影響其他台的結果。例外必須在那一台的查詢內部就攔下來。轉成那台的錯誤狀態,不准往上到 webhook 層。
還有一個地方要特別說明一下。一般查詢預設抓三個月。如果三個月內一筆都沒有、而且至少有一台回 ok,服務會自動改抓十二個月再查一次。但只要每一台都失敗或都需要重新授權,就維持三個月不動。
這條規則背後是一個判斷:「查不到」跟「查失敗」是兩件不同的事。分不清楚的程式會在全部失敗的時候跑去改抓十二個月。做了白工,還讓使用者以為只是資料比較舊。
今天我們就來處理這件事:要怎麼分辨伺服器到底在跟你說什麼呢?
實際觸發五種情形
到目前為止這個 app 的每個請求都成功了。要處理錯誤,得先有錯誤。
我對這台 sandbox 試了五種弄壞它的方式,全部觸發成功:

第三列就是今天最重要的一件事。
不是每個錯誤都回 OperationOutcome
FHIR 規範定義了 OperationOutcome 這個資源,專門用來回報錯誤。教科書上的錯誤處理就是解析它。
但那個 401 回來的是這樣:
HTTP/1.1 401 Unauthorized
Content-Type: text/plain; charset=utf-8
Invalid token: jwt malformed
沒有 JSON,沒有 issue 陣列,就一句純文字。
原因很合理:這個錯誤是授權層擋下來的,請求根本沒走到 FHIR 伺服器。授權層不是 FHIR 伺服器,它沒有義務回 FHIR 資源。
所以下面這種寫法會在這裡爆掉:
if (!response.ok) {
const outcome = await response.json() // 401 走到這裡直接丟例外
showError(outcome.issue[0].diagnostics)
}
而且爆掉的方式很低級。使用者看到的不是「請重新登入」,是一個 JSON 解析失敗的訊息。那跟真正的問題完全無關,工程師照著去查也查不到。
先看 Content-Type 再決定怎麼解析,這是這篇最實用的一行:
const contentType = response.headers.get('content-type') ?? ''
if (/\bjson\b/i.test(contentType)) {
// 解析 OperationOutcome
} else {
// 當純文字讀
}
用正則是因為 media type 的大小寫不固定。Application/FHIR+JSON 也合法,includes('json') 會漏掉它,把一份 OperationOutcome 當純文字讀。
OperationOutcome 裡面該讀哪一層
看一筆實際回來的:
{
"resourceType": "OperationOutcome",
"issue": [
{
"severity": "error",
"code": "processing",
"diagnostics": "Resource Observation/no-such-observation-xyz is not known"
}
]
}
issue 是陣列,一次可以回報好幾個問題。severity 有四種:fatal、error、warning、information。所以要先挑出真正擋住你的那些:
const blocking = issues.filter(
(issue) => issue.severity === 'error' || issue.severity === 'fatal'
)
const pick = (blocking.length ? blocking : issues)[0]
接下來是關鍵:diagnostics 不能直接拿給使用者看。
看看這台實際吐出來的東西:
Failed to parse request body as JSON resource. Error was:
Failed to parse JSON encoded FHIR content: java.io.EOFException:
End of input at line 1 column 31 path $.resourceType
java.io.EOFException 出現在使用者的畫面上,那是把伺服器的內部實作洩漏出去。而使用者完全無法從中知道該怎麼辦。
給人看的那一句應該讀 details.text。那個欄位是規範留給「寫給人看的說明」的地方。但這台的五種錯誤沒有一個帶 details,全部只有 diagnostics。
所以要有一套自己準備的訊息,按狀態碼對照:
const BY_STATUS = {
400: '送出去的內容有問題,請檢查欄位。',
401: '授權已失效,請重新授權。',
403: '這個帳號沒有權限做這件事。',
404: '找不到這筆資料。',
429: '請求太頻繁,請稍後再試。',
500: '伺服器出錯了,稍後再試一次。',
}
優先序是:details.text 有就用它,沒有就退到這張表。diagnostics 一律進 console 給工程師,不上畫面。
可重試與不可重試
錯誤要分兩類,因為處置方式完全不同。
可重試的是「再送一次可能就好了」:伺服器忙不過來、暫時性的網路問題、被限流。這類的處置是等一下再試,使用者甚至不必知道。
const RETRIABLE = new Set([429, 500, 502, 503, 504])
不可重試的是你自己送錯了。400 是這個請求伺服器處理不了,401 是授權無效,404 是東西不在。這些重試一百次結果都一樣,重試只是在浪費時間跟伺服器資源。
有一個要特別小心:401 不能無腦重試。你可能會想「token 過期了,refresh 一次再送」。token 剛好過期時,這是對的。
但 401 的成因不只有過期。token 被撤銷、或是格式根本就壞掉,也都是 401。這兩種 refresh 完再送還是 401,就變成無限迴圈。
順帶講一個常見的誤會:scope 不夠通常不是 401。RFC 6750 給它配的是 403 的 insufficient_scope,401 配的是 invalid_token,也就是過期、撤銷、格式錯。拿到那種 403 時 refresh 沒用,要重新授權把缺的 scope 要回來。
fhirclient 的選擇更保守。我翻了它的原始碼,它在送出請求之前就先看要不要換 token。三個條件要一起成立:有 access_token、有 refresh_token、離過期不到十秒。
但 401 真的回來之後,它一次都不重試,直接把整個 session 清掉要你重新授權。debug 訊息寫得很白:Auto-refresh failed! Please re-launch the app.
要誠實講一件事:今天這五種情境全部落在不可重試那一組。可重試那條路的程式碼寫得出來,但你在這裡驗證不了它,別把它當成測過的東西。

主線用的是 client.request(),它丟什麼
上面那些都是自己用 fetch 打出來的。但第三幕的主線是 client.request(),它失敗時丟的東西長得不一樣。
我把 fhirclient 那個 vendor 檔翻開來看了。它丟的是一個 HttpError,繼承自 Error:
class HttpError extends Error {
constructor(response) {
super(`${response.status} ${response.statusText}\nURL: ${response.url}`)
this.name = 'HttpError'
this.response = response
this.statusCode = response.status
this.status = response.status
this.statusText = response.statusText
}
}
error.message 開頭兩行是狀態碼加 statusText,然後是網址:
404
URL: https://launch.smarthealthit.org/v/r4/sim/…/fhir/Observation/no-such-observation-xyz
404 後面那個空格不是排版失誤,這台 sandbox 的 statusText 是空字串。
那 OperationOutcome 呢?直覺會想:Response 就掛在 error.response 上,clone 一份餵給 describeFailure() 不就好了。
這條路走不通。看 fhirclient 收到回應後的那個檢查:
async function checkResponse(response) {
if (!response.ok) {
const error = new HttpError(response)
throw (await error.parse(), error)
}
return response
}
throw 之前先 await error.parse()。而 parse() 會讀 body:
async parse() {
if (!this.response.bodyUsed) {
const type = this.response.headers.get('content-type') || 'text/plain'
if (type.match(/\bjson\b/i)) {
this.message += '\n\n' + JSON.stringify(await this.response.json(), null, 4)
} else if (type.match(/^text\//i)) {
this.message += '\n\n' + (await this.response.text())
}
}
return this
}
(這兩段是照 vendor 檔重排的可讀版。OAuth error 那一支跟外層的 try 都省略了。)
它讀 body 是有條件的,Content-Type 要是 JSON 或 text/ 開頭。這五種都符合。
所以你拿到 HttpError 的時候,body 已經被讀完了,bodyUsed 是 true。這時候呼叫 clone() 不是拿到一個空的 stream,是直接丟 TypeError。
但被讀走的東西沒有消失。parse() 把整個 JSON 用 JSON.stringify 接在 message 後面了。所以剛才那段 message 其實還有下半截:
404
URL: https://launch.smarthealthit.org/v/r4/sim/…/fhir/Observation/no-such-observation-xyz
{
"resourceType": "OperationOutcome",
"issue": [
{
"severity": "error",
"code": "processing",
"diagnostics": "Resource Observation/no-such-observation-xyz is not known"
}
]
}
OperationOutcome 一直都在,只是變成了文字。
所以這條路要讀的是 status 跟 message,不要去碰 response:
export function describeClientError(error) {
if (typeof error?.status === 'number') {
return {
status: error.status,
retriable: isRetriable(error.status),
userMessage: BY_STATUS[error.status] ?? `請求失敗,HTTP ${error.status}。`,
developerMessage: error.message ?? null,
issueCode: null,
issueCount: 0,
}
}
return {
status: 0,
retriable: true,
userMessage: '連不上伺服器,請檢查網路後再試一次。',
developerMessage: error?.message ?? String(error),
issueCode: null,
issueCount: 0,
}
}
issueCode 跟 issueCount 固定是 null 與 0,這是走 client.request() 的代價。你拿得到那段文字,但拿不到一個可以讀 issue[0].code 的物件。真的需要結構化的 issue,就得像前面那樣自己用 fetch,在讀 body 之前先 clone()。
順帶一提,parse() 判斷 content-type 用的是 contentType.match(/\bjson\b/i)。所以前面那一課不是本系列自己發明的慣例,fhirclient 的判斷順序也是先看 content-type 再決定怎麼讀。
第三類:連狀態碼都沒有
上面兩種都假設伺服器有回東西。還有一種情況是根本沒有回應。
斷網、DNS 查不到伺服器的位址、CORS 預檢被擋,這三種是 fetch 自己 reject。丟的是 TypeError 不是 HttpError,身上連 status 都沒有。
所以判斷式問的是「有沒有一個數字的 status」,這就是上面那段 if (typeof error?.status === 'number') 的用意。沒有 status 的那條路當成可重試,因為網路問題常常是暫時的。
但這個分類會漏。CORS 設定錯誤也沒有 status,重試幾次都一樣。

這一類我沒辦法給你實測輸出,你把 Wi-Fi 關掉試一次就會看到。
Console 上的字每家瀏覽器不同,Chrome 是 TypeError: Failed to fetch。重點不是那串字,是型別為 TypeError 而不是 HttpError。
跟著做:讓錯誤自己現形
起點是 day18 結束時的專案,加上 write.js。今天新增 errors.js,並在畫面上放六顆按鈕故意觸發失敗。
第一步,新增 errors.js
const BY_STATUS = {
400: '送出去的內容有問題,請檢查欄位。',
401: '授權已失效,請重新授權。',
403: '這個帳號沒有權限做這件事。',
404: '找不到這筆資料。',
410: '這筆資料已經被刪除。',
422: '這筆資料沒通過伺服器的檢查。',
429: '請求太頻繁,請稍後再試。',
500: '伺服器出錯了,稍後再試一次。',
503: '伺服器暫時無法服務,稍後再試一次。',
}
const RETRIABLE = new Set([429, 500, 502, 503, 504])
export function isRetriable(status) {
return RETRIABLE.has(status)
}
function fromOperationOutcome(payload) {
if (payload?.resourceType !== 'OperationOutcome') return null
const issues = payload.issue ?? []
const blocking = issues.filter(
(issue) => issue.severity === 'error' || issue.severity === 'fatal'
)
const pick = (blocking.length ? blocking : issues)[0]
if (!pick) return null
return {
userMessage: pick.details?.text ?? null,
developerMessage: pick.diagnostics ?? pick.details?.text ?? null,
issueCode: pick.code ?? null,
issueCount: issues.length,
}
}
fromOperationOutcome 回 null 的情況有兩種:payload 根本不是 OperationOutcome,或者 issue 是空陣列。兩種都要防,因為伺服器回什麼你控制不了。
第二步,主函式先看 Content-Type
export async function describeFailure(response) {
const status = response.status
const contentType = response.headers.get('content-type') ?? ''
const fallback = BY_STATUS[status] ?? `請求失敗,HTTP ${status}。`
let developerMessage = null
let issueCode = null
let issueCount = 0
let userMessage = null
if (/\bjson\b/i.test(contentType)) {
try {
const parsed = fromOperationOutcome(await response.json())
if (parsed) {
userMessage = parsed.userMessage
developerMessage = parsed.developerMessage
issueCode = parsed.issueCode
issueCount = parsed.issueCount
}
} catch {
developerMessage = '回應宣稱是 JSON 但解不開'
}
} else {
developerMessage = (await response.text()).slice(0, 200)
}
return {
status,
retriable: isRetriable(status),
userMessage: userMessage ?? fallback,
developerMessage,
issueCode,
issueCount,
}
}
那個 try 不是多餘的。Content-Type 說是 JSON,不代表 body 真的是合法 JSON。代理伺服器或閘道器塞一頁 HTML 錯誤頁進來,是很常見的事。
.slice(0, 200) 是因為純文字錯誤可能是一整頁 HTML,不截斷會把 console 洗版。
第三步,六顆按鈕
六顆按鈕,前五顆對應前面那五種弄壞的方式:
const FAILURE_CASES = {
notfound: (base) => [`${base}/Observation/no-such-observation-xyz`, {}],
badparam: (base) => [`${base}/Observation?totally-not-a-param=1`, {}],
badtoken: (base, patientId) => [
`${base}/Patient/${patientId}`,
{ headers: { Authorization: 'Bearer not-a-real-token' } },
],
mismatch: (base) => [
`${base}/Observation`,
{
method: 'POST',
headers: { 'Content-Type': 'application/fhir+json' },
body: JSON.stringify({ resourceType: 'Patient' }),
},
],
// 不是送錯型別,是根本收不了尾。Content-Type 說是 JSON,body 不是。
badjson: (base) => [
`${base}/Observation`,
{
method: 'POST',
headers: { 'Content-Type': 'application/fhir+json' },
body: '{"resourceType": "Observation"',
},
],
}
第六顆叫 clientfail,走的是 client.request() 不是 fetch,所以不在表裡:
import { describeFailure, describeClientError } from './errors.js'
async function tryFailure(client, name) {
try {
if (name === 'clientfail') {
await client.request('Observation/no-such-observation-xyz')
return // 這個請求必定失敗,走到這裡代表伺服器行為與預期不符
}
const [url, init] = FAILURE_CASES[name](client.state.serverUrl, client.patient.id)
report(name, await describeFailure(await fetch(url, init)))
} catch (error) {
report(name, describeClientError(error))
}
}
function report(name, failure) {
console.log(`[${name}]`, failure.status, failure.retriable ? '可重試' : '不重試')
console.log(' 給使用者:', failure.userMessage)
console.log(' 給開發者:', failure.developerMessage)
errorResult.className = 'mt-2 text-sm text-rose-700'
errorResult.textContent = `HTTP ${failure.status}:${failure.userMessage}`
}
errorResult 是畫面上那行紅字,在檔案開頭抓好:
const errorResult = document.querySelector('#error-result')
按鈕的事件綁定放在 showPatient() 裡,要等授權完成拿到 client 才綁得上:
for (const button of document.querySelectorAll('[data-case]')) {
button.addEventListener('click', () => tryFailure(client, button.dataset.case))
}
那個 try 一次管兩件事。一是接住 clientfail 丟的 HttpError,交給 describeClientError。二是接住斷網時 fetch 丟的 TypeError,把 Wi-Fi 關掉再按前五顆就會走到。少了它,斷網就是一個沒人接的 Promise rejection,畫面什麼都不會動。
base 傳的是 client.state.serverUrl。那是 fhirclient 存 FHIR base URL 的地方,day18 寫入時用的也是它。病人 id 從 client.patient.id 傳進去,不要寫死,換一位病人才不用改程式。
第四步,六顆都按一次
實際跑出來的結果:

畫面上只出現「給使用者」那一句,java.io.EOFException 那類東西留在 console。
三種 400 的「給使用者」那一句完全相同,因為這台沒有一個 OperationOutcome 帶 details.text。實際除錯要看 console 那一行,三者各有一個好認的關鍵字。搜尋參數打錯是 Unknown search parameter,資源型別不符是 Incorrect resource type found,body 不是合法 JSON 則會出現 java.io.EOFException。
畫面上分不出來的三種錯誤,在 console 裡一眼就分得出來。這就是「diagnostics 留給 console」的實際用處。
notfound 跟 clientfail 也共用同一句,兩者都是 404。但 console 那一行差很多。notfound 印的是 describeFailure() 從 OperationOutcome 挑出來的 diagnostics。clientfail 印的是整個 HttpError 的 message,裡面有 parse() 接上的完整 JSON。
特別看 badtoken 那次。程式沒有丟例外,照樣產出一句可讀的訊息,這就是先看 Content-Type 換來的。
火線超人的三條規則
回到開頭那些規則,現在它們比較好懂了。
每台伺服器各自算逾時,例外就地攔下。 一台醫院掛掉,使用者應該看到其他幾家的資料,加上一句「某某醫院暫時連不上」。整個畫面空白是最糟的選項。
token 過期的伺服器標成需要重新授權,不查詢。 這是把錯誤再細分。有一種失敗的正確處置既不是重試也不是放棄,是請使用者去按一個按鈕。
全部失敗時不改抓十二個月。 因為那時候你根本不知道有沒有資料,改了只是再失敗一次。
三條規則的共同點是一句話。錯誤不是布林值,它有種類,不同種類要走不同的路。
完整可跑的版本在 GitHub 上的 day19-error-handling,想先看跑起來的樣子可以直接開線上版。
小結
先看 Content-Type 再解析,diagnostics 留給 console。可重試與不可重試要分開,而 401 不能無腦重試。
明天處理量的問題。今天這位病人只有三筆病況兩筆用藥,資料一頁就裝完了。真實病歷不會這麼客氣,而 FHIR 的分頁機制跟你想像的不一樣。