iT邦幫忙

2026 iThome 鐵人賽

DAY 19
0

本文同步發表於個人部落格:FHIR 伺服器的處理錯誤


火線超人可能同時查好幾家醫院的資料。

它的規格裡有一條寫得很死。每一台伺服器的查詢都有各自的逾時上限,預設五秒。一台逾時或出錯,絕對不能影響其他台的結果。例外必須在那一台的查詢內部就攔下來。轉成那台的錯誤狀態,不准往上到 webhook 層。

還有一個地方要特別說明一下。一般查詢預設抓三個月。如果三個月內一筆都沒有、而且至少有一台回 ok,服務會自動改抓十二個月再查一次。但只要每一台都失敗或都需要重新授權,就維持三個月不動。

這條規則背後是一個判斷:「查不到」跟「查失敗」是兩件不同的事。分不清楚的程式會在全部失敗的時候跑去改抓十二個月。做了白工,還讓使用者以為只是資料比較舊。

今天我們就來處理這件事:要怎麼分辨伺服器到底在跟你說什麼呢?

實際觸發五種情形

到目前為止這個 app 的每個請求都成功了。要處理錯誤,得先有錯誤。

我對這台 sandbox 試了五種弄壞它的方式,全部觸發成功:

五種錯誤情境的對照圖,米色底,標題「五種錯誤,四種是 OperationOutcome」,副標「describeFailure() 對五種情境各跑一次,逐筆記下它讀到什麼」。畫面主體是一張表,最上方一排深藍色底白字的欄標題,由左而右依序是情境、狀態碼、Content-Type、issueCode、issueCount。往下五列,第一、二、四、五列都是白底加淺灰外框:第一列情境是讀一個不存在的 id,狀態碼 404,Content-Type 是 application/fhir+json,issueCode 是 processing,issueCount 是 1;第二列情境是用一個不支援的參數,狀態碼 400,其餘三欄與第一列相同;第四列情境是送錯資源型別,狀態碼 400,其餘三欄相同;第五列情境是 body 不是合法 JSON,狀態碼 400,其餘三欄相同。夾在中間的第三列整列是珊瑚色底白字,情境是帶一個亂寫的 token,狀態碼 401 的字比其他列都大,Content-Type 是 text/plain,issueCode 是 null,issueCount 是 0。表格下方一整條深藍色橫幅,左半邊第一行白色粗體寫「401 沒有 OperationOutcome 可以解析」,第二行寫 Content-Type 是 text/plain、issueCode 是 null、issueCount 是 0,這三個值以珊瑚色標示;右半邊上一行灰字寫「無條件呼叫 response.json()」,下一行白色粗體寫「就在這一列丟例外」。圖片最下方一行灰字:三種 400 共用同一句給使用者的訊息,因為這台的 OperationOutcome 沒有一個帶 details.text

第三列就是今天最重要的一件事。

不是每個錯誤都回 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.

要誠實講一件事:今天這五種情境全部落在不可重試那一組。可重試那條路的程式碼寫得出來,但你在這裡驗證不了它,別把它當成測過的東西。

可重試與不可重試狀態碼的分組圖,米色底,標題「11 個狀態碼分兩組,實測全落在不重試那邊」,副標「isRetriable() 的判定逐一驗過,珊瑚色是這次真的觸發到的」。畫面上左右兩個區塊,左邊是一個佔了將近三分之二寬度的深藍色大方塊,右邊是一個較窄較矮的白底虛線外框方塊。左方塊上方灰字寫 isRetriable() 回 false,底下白色粗體寫「不重試,6 個」,再往下是兩欄三列共六個色塊:第一列左邊珊瑚色底寫 400 加一行小字「觸發 3 次」,右邊珊瑚色底寫 401 加「觸發 1 次」;第二列左邊深藍色底寫 403 加灰字「未觸發」,右邊珊瑚色底寫 404 加「觸發 1 次」;第三列左右都是深藍色底,分別寫 410 與 422,各配一行「未觸發」。右方塊上方灰字寫 isRetriable() 回 true,底下寫「可重試,5 個」,往下是五個淺灰底方塊,數字全部灰化,依序為 429、500、502、503、504,方塊下方一行灰字寫「五個一次都沒觸發到」。兩個方塊下方是一條白底、左緣有一條珊瑚色直條的橫帶,第一行粗體寫「這台 sandbox 跑不出可重試的情境」,第二行寫五種情境全部落在不重試那一組,可重試那條路寫得出來,但在這裡驗證不了。圖片最下方一行灰字:觸發次數合計 5 次,對應 describeFailure() 跑過的五種情境

主線用的是 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,重試幾次都一樣。

兩種例外的分岔圖,米色底,標題「細節在 message 裡,不在 response」,副標「讀 fhirclient 2.6.3 的 vendor 檔本體,不是文件也不是推測」。畫面最上方一條橫跨全寬的深藍色橫帶,左邊灰字寫「先問這一個」,接著白色等寬大字 error.status,最右邊灰字寫「兩條路只差這一個判斷」。橫帶底部中央往下引出兩條線,各自轉一個圓角直角再往下:左邊那條是深藍色實線,接到左下方的方塊;右邊那條是灰色虛線,接到右下方的方塊。左下方是一個佔了大半寬度的深藍色方塊,最上面等寬白色大字 HttpError,底下一行灰字寫 response.ok 為 false 時丟出,checkResponse 在 throw 之前已經 await 過 parse()。再往下一行小字標「error.message 實際長這樣」,接一塊更深的深藍色區域,分成三個部分:第一行是珊瑚色的 404,後面空著沒有 statusText;第二行灰字寫 URL 冒號後接 https://launch.smarthealthit.org/v/r4/sim/…/fhir/Observation/no-such-observation-xyz,這一行折成兩行顯示;空一行之後是淺綠色的一段 JSON,內容是 resourceType 為 OperationOutcome、issue 陣列裡 severity 為 error、code 為 processing,後面被刪節號截斷。再往下是三個並排的中藍色小方塊,各有一行灰色小字與一行白色粗體字,依序是第一段配「狀態碼與 statusText」、第二段配 URL、第三段配「parse() 接上的 body」。方塊最底下一行珊瑚色字寫「OperationOutcome 在第三段,是文字不是能讀 issue 的物件」。右下方是一個較窄較矮的白底虛線外框方塊,最上面等寬灰字 TypeError,底下一行寫 fetch 本身就 reject,不是 HttpError。往下一行小字標「這條路上拿得到的」,接一塊淺灰底區域,寫著 error.status 是 undefined,其中 undefined 以珊瑚色粗體標示。再往下三個灰色圓點項目,依序是斷網、DNS 解不出來、CORS 預檢被擋。方塊最底下掛一個淺灰底膠囊標記寫「本次實跑無法穩定觸發」。圖片最下方兩行灰字:body 在 throw 之前就被 parse() 讀掉了,事後 clone() 只會丟 TypeError。要結構化的 issue 就得自己用 fetch,在讀之前先 clone。parse() 的判斷式是 contentType.match(/\bjson\b/i)

這一類我沒辦法給你實測輸出,你把 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 傳進去,不要寫死,換一位病人才不用改程式。

第四步,六顆都按一次

實際跑出來的結果:

執行結果圖,淺灰綠底色,標題「六顆按鈕跑出來的樣子」,副標「六顆各按一次,例外都被接住了,六次都印得出一句話」。畫面中央一個白色圓角面板,最上一列是三個灰色小圓點代表瀏覽器視窗,沒有網址列。往下一行灰字寫「六顆按鈕各按一次」,底下排著六顆等寬字按鈕,依序是 notfound、badparam、badtoken、mismatch、badjson、clientfail,其中 badtoken 那顆是白底珊瑚色字加珊瑚色外框,clientfail 那顆是白底深藍色字加深藍色外框,其餘四顆是灰框灰字。再往下是一條淺灰底的 Console 標籤,底下是六段輸出,每段三行,段與段之間有一條細灰線,每段第一行都是中括號包住的按鈕名稱接狀態碼與可重試判定。第一段沒有編號,第一行等寬字是 notfound 接 404 不重試,第二行「給使用者:」接「找不到這筆資料。」,第三行「給開發者:」接 Resource Observation/no-such-observation-xyz is not known。第二段左緣掛綠色編號 1,第一行是 badparam 接 400 不重試,第二行給使用者是「送出去的內容有問題,請檢查欄位。」,第三行給開發者是 Unknown search parameter 引號包住的 totally-not-a-param,接 Value search parameters for this search are 冒號,再接一串以中括號開頭的合法參數清單,開頭是 _id 與 _language,後面被刪節號截斷。第三段左緣掛綠色編號 2,整段的按鈕名稱與狀態碼都是珊瑚色,第一行是 badtoken 接 401 不重試,第二行給使用者是「授權已失效,請重新授權。」,第三行給開發者是珊瑚色的 Invalid token 冒號後接 jwt malformed。第四段沒有編號,第一行是 mismatch 接 400 不重試,第二行給使用者與第二段逐字相同,第三行給開發者是 Failed to parse request body as JSON resource. Error was 冒號後接 Incorrect resource type found, expected 引號包住的 Observation,再被刪節號截斷。第五段沒有編號,第一行是 badjson 接 400 不重試,第二行給使用者與第二段逐字相同,第三行給開發者是同樣的 Failed to parse request body as JSON resource. Error was 冒號後接 Failed to parse JSON encoded FHIR content 冒號再接 java.io.EOFException,被刪節號截斷。第六段左緣掛綠色編號 3,按鈕名稱與狀態碼都是深藍色,第一行是 clientfail 接 404 不重試,第二行給使用者與第一段逐字相同,第三行給開發者佔了四行,第一行只有一個 404 後面空著,第二行是 URL 冒號後接 https 開頭的 sandbox 網址結尾是 Observation/no-such-observation-xyz,空一行之後接一段以左大括號開頭的 JSON,內容是 resourceType 為 OperationOutcome、issue 陣列裡 severity 為 error,後面被刪節號截斷。面板下方三條編號註記:編號 1 寫 badparam、mismatch 與 badjson 的「給使用者」逐字相同,因為這台的 OperationOutcome 沒有一個帶 details.text,三種 400 只能共用同一句;編號 2 寫 badtoken 的 issueCode 是 null、issueCount 是 0,Content-Type 是 text/plain,沒有 OperationOutcome 可以解析,其中 null 與 0 以珊瑚色標示;編號 3 寫 clientfail 是唯一走 describeClientError 的一顆,issueCode 與 issueCount 同樣是 null 與 0,但 OperationOutcome 沒有消失,它被 parse() 接在 message 後面,其中 null 與 0 以深藍色標示

畫面上只出現「給使用者」那一句,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 的分頁機制跟你想像的不一樣。


上一篇
Day18 - 寫回 FHIR
下一篇
Day20 - FHIR 搜尋,分頁不要自己算
系列文
SMART on FHIR 開發之路:30 天做一個跨醫院的 app 共 30 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言