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(過去只能 parseerror.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 已重生)