跳轉到

API Error Contract Integration Guide — Frontend (PR #1406)

Backend branch: fixed/1399-error-envelope-contract → cortex/dev API base: http://localhost:8000(dev) 生效時間: merge 後 cortex-api 以 --reload 立即生效;SIT 依 CD 部署

這份文件回答的是「前端現在的 code 要改哪些行」。 API 規格本身見 docs/api-reference.md §通用 Response 格式。

重要前提:後端的錯誤 body 形狀本身沒有改變。 全域 handler 從 v0.1.0(2026-02-02)起就送 {"error": {code, message, details}},是文件與 OpenAPI spec 一直寫錯。所以本 PR 對前端的 主要影響不是「後端變了」,而是「如果你照著錯誤的 spec 產型別或寫 catch,那份 code 一直是錯的, 現在 spec 對了,型別要重生」。

真正的執行期變更只有兩處,列在 §3。

1. 欄位改名對照表(Breaking)

無欄位改名。錯誤 body 的欄位名自始未變。

2. 新欄位(additive)

Endpoint 欄位 型別 語意
全部(OpenAPI 宣告層) 429 response { error: string } 限流回應。error 在這裡是字串,與一般錯誤 envelope 的 error 物件同名不同型
全部(OpenAPI 宣告層) default response ErrorResponse 涵蓋端點未列舉的所有狀態碼
POST /api/portfolio/transactions/batch error.details object 過去恆為 {};現在帶結構化錯誤內容(見 §3-2)
/api/mas/* error.details object 同上
GET /api/stock-rules/{ticker}/{source} 整個 200 body StockRuleSourceResponse 過去 OpenAPI 對此端點的成功 body 完全空白

3. 行為改變(Status code / shape)

3-1. GET /api/stock-rules/{ticker}/{source} 查無資料:404 → 200

情境 修前 修後
該 ticker+source 有啟用中設定 200 + config 不變
該 ticker 存在但此 source 無設定 404 {"detail": "No active sta config for 2330.TW"} 200 + 空 config(識別欄保留)
該 ticker 完全不存在 同上 404 200 + 空 config

為什麼:同一個 router 的另外三條端點(/{ticker}、/{ticker}/summary、/{ticker}/history?source=X) 查無資料一律回 200 + 空容器。其中 history?source=sta 與 /{ticker}/sta 問的是同一個問題, 卻一個回 200 {"entries": []}、一個回 404。這也是全庫唯一送出 {"detail": "<字串>"} 的端點。

3-2. 物件型錯誤 detail:字串化 → 放進 error.details

情境 修前 修後
POST /api/portfolio/transactions/batch 某一筆失敗 error.message = 整個 dict 的 Python repr(單引號,無法 JSON.parse),error.details = {} error.message = 人可讀訊息,error.details = 結構化物件(含 item.index / item.ticker)
/api/mas/* 輸入序列化失敗 error.code = "ERROR",error.message = dict repr error.code = "INPUT_SERIALIZATION_ERROR",error.message = 實際訊息

字串型 detail(絕大多數端點)行為完全未變,逐欄相同。

4. 五情境真實 response

以下 1~5 為 2026-08-20 對 dev cortex-api 實打(docker exec cortex-api curl)。 情境 6 是本 PR 的新行為,取自 integration test 對真實 Postgres 的實測。

4-1 端點自己拒絕(envelope,涵蓋絕大多數 4xx/5xx)

curl -i "$API/api/day-trading/stocks/9999.TW"
HTTP 404
{"error":{"code":"Stock not found: 9999.TW","message":"Stock not found: 9999.TW","details":{}}}

4-2 端點自己拒絕的 422(不是 FastAPI 參數驗證)

curl -i "$API/api/signals/stock/2330.TW?qryDate=not-a-date"
HTTP 422
{"error":{"code":"Invalid date format. Expected YYYY-MM-DD.","message":"Invalid date format. Expected YYYY-MM-DD.","details":{}}}

4-3 FastAPI 參數驗證失敗的 422(原生 detail 陣列)

curl -i "$API/api/etf-holdings/XXX.TW/changes/recent?days=0"
HTTP 422
{"detail":[{"type":"greater_than_equal","loc":["query","days"],"msg":"Input should be greater than or equal to 1","input":"0","ctx":{"ge":1}}]}

⚠️ 同一個 422 有兩種形狀。 先看有沒有 error 鍵,再決定怎麼讀。

4-4 認證失敗(AuthError,同樣走 envelope)

curl -i "$API/api/members/me" -H "Authorization: Bearer-bogus"
HTTP 401
{"error":{"code":"INVALID_TOKEN","message":"Authorization header must be 'Bearer <token>'","details":{}}}

4-5 未知路由(Starlette router 層,不經全域 handler)

curl -i "$API/api/definitely-not-a-route"
HTTP 404
{"detail":"Not Found"}

4-6 無資料(本 PR 新行為)

curl -i "$API/api/stock-rules/ZZZZ9.TW/sta"
HTTP 200
{"ticker":"ZZZZ9.TW","config_source":"sta","version":null,"computed_at":null,
 "data_range":{"start":null,"end":null},"sample_count":0,"quality_tier":null,
 "warnings":[],"n_rules":0,"channels":{}}

4-7 限流(slowapi,error 是字串)

HTTP 429
{"error":"Rate limit exceeded: 10 per 1 minute"}

5. Consumer 端 helper(建議實作)

// 一個 parser 吃掉全部六種形狀。重點是「先判型別,再取值」——
// `error` 這個鍵在 envelope 是物件、在 429 是字串。
export type ParsedApiError = {
  code: string;
  message: string;
  details: Record<string, unknown>;
  /** true 時 message 只適合記 log,要顯示請讀 fieldErrors */
  fieldErrors?: Array<{ loc: (string | number)[]; msg: string }>;
};

export function parseApiError(status: number, body: unknown): ParsedApiError {
  if (typeof body !== "object" || body === null) {
    // CORS preflight 被拒時是純文字,不是 JSON —— 呼叫端要先 try/catch JSON.parse
    return { code: "NON_JSON", message: String(body), details: {} };
  }
  const b = body as Record<string, unknown>;

  // 限流:error 是字串
  if (typeof b.error === "string") {
    return { code: "RATE_LIMITED", message: b.error, details: {} };
  }
  // 一般錯誤 envelope
  if (b.error && typeof b.error === "object") {
    const e = b.error as Record<string, unknown>;
    return {
      code: String(e.code ?? "ERROR"),
      message: String(e.message ?? ""),
      details: (e.details as Record<string, unknown>) ?? {},
    };
  }
  // FastAPI 參數驗證:detail 是陣列
  if (Array.isArray(b.detail)) {
    return {
      code: "VALIDATION_ERROR",
      message: "Request validation failed",
      details: {},
      fieldErrors: b.detail as ParsedApiError["fieldErrors"],
    };
  }
  // Starlette router 層(未知路由 / method 不符):detail 是字串
  if (typeof b.detail === "string") {
    return { code: "NOT_ROUTED", message: b.detail, details: {} };
  }
  return { code: "UNKNOWN", message: JSON.stringify(body), details: {} };
}

6. Consumer 完整 type diff

-// 由舊 openapi.json 產生的型別 —— 這個形狀 API 從來沒送出過
-export interface ApiError {
-  detail: string;
-}
+export interface ErrorDetail {
+  code: string;
+  message: string;
+  details: Record<string, unknown>;
+}
+
+export interface ErrorResponse {
+  error: ErrorDetail;
+}
+
+/** 429 專用:error 是字串,不是 ErrorDetail */
+export interface RateLimitError {
+  error: string;
+}
+
+/** FastAPI 參數驗證(型別錯、缺必填、ge/le 越界) */
+export interface HTTPValidationError {
+  detail: Array<{
+    loc: (string | number)[];
+    msg: string;
+    type: string;
+    input?: unknown;
+    ctx?: Record<string, unknown>;
+  }>;
+}

 // stock-rules 單一 source
-type StockRuleSource = {
-  ticker: string;
-  config_source: string;
-  version: number;
-  computed_at: string;
-  data_range: { start: string; end: string };
-  sample_count: number;
-  quality_tier: string;
-  warnings: string[];
-  n_rules: number;
-  channels: Record<string, unknown>;
-};
+type StockRuleSource = {
+  ticker: string;
+  config_source: string;
+  version: number | null;
+  computed_at: string | null;
+  data_range: { start: string | null; end: string | null };
+  sample_count: number;
+  quality_tier: string | null;
+  warnings: string[];
+  n_rules: number;
+  channels: Record<string, unknown>;
+};

7. Consumer 遷移 checklist

  • [ ] 從 docs/assets/openapi.json 重新產生型別(舊的 4xx 型別全庫都是錯的)
  • [ ] grep -rn 'error.detail\|\.detail\b' src/ —— 找出所有讀 detail 的錯誤處理
  • [ ] 讀單一字串錯誤訊息的地方:body.detail → body.error.message
  • [ ] 讀參數驗證陣列的地方(body.detail[0].msg):不要改,那條路徑仍是原生形狀
  • [ ] 限流處理:確認沒有寫 body.error.code —— 429 的 error 是字串,會 throw
  • [ ] grep -rn 'stock-rules' src/ —— 若有以 status === 404 判「無規則」,改判 n_rules === 0 或 version === null
  • [ ] stock-rules 的型別把 version / computed_at / quality_tier / data_range.* 改成 nullable
  • [ ] batch 交易錯誤:改讀 error.details.item(過去只能 parse error.message 的 Python repr,那段 code 可以刪掉)
  • [ ] CORS preflight 被拒時回的是純文字,確認 JSON.parse 有包 try/catch
  • [ ] 導入 §5 的 parseApiError,讓上述判斷集中在一處

8. 參考

  • PR: https://github.com/rdaoshiken/Cortex/pull/1406
  • Issues: #1399(主)、#1403、#1404、#1405
  • 後端規格:docs/api-reference.md §通用 Response 格式(六種錯誤形狀的權威定義)
  • 產生器來源:docs/assets/openapi.json(本 PR 已重生)