// Package conversion error 定義。 // // 對齊 conversion.md §6 錯誤碼 mapping 與 api-conversion.md §錯誤碼總覽。 // // 設計原則: // - 用 sentinel error(package-level var)+ wrap 模式,不用 error code string // 做 equality check(caller 用 errors.Is 判斷) // - 每個 sentinel 都對應一個對外 error code(見 ErrorCode() helper) // - HTTP status mapping 與 message 在 handler 層處理(見 internal/api/conversion.go), // 避免 conversion package 依賴 gin // // Phase 0.8 conversion (見 .autoflow/04-architecture/conversion.md §6) package conversion import "errors" // Sentinel errors — handler 層用 errors.Is 比對。 var ( // ErrForbidden — job 不屬於當前 user。對應 HTTP 403 / code "forbidden"。 ErrForbidden = errors.New("conversion: forbidden") // ErrJobNotFound — job_id 不存在或已過期。對應 HTTP 404 / code "not_found"。 ErrJobNotFound = errors.New("conversion: job not found") // ErrJobNotCompleted — job 還沒 completed,不能 promote / download。 // 對應 HTTP 409 / code "job_not_completed"。 ErrJobNotCompleted = errors.New("conversion: job not completed") // ErrActiveJobExists — 同 user 已有 active job。 // 對應 HTTP 409 / code "active_job_exists"。 // caller 可用 ActiveJobError struct 取得衝突中的 job 資訊(見下方)。 ErrActiveJobExists = errors.New("conversion: user already has active job") // ErrValidationFailed — 上傳的 multipart 內容格式錯誤(converter 4xx validation_error / invalid_multipart)。 // 對應 HTTP 400 / code "validation_failed"。 ErrValidationFailed = errors.New("conversion: validation failed") // ErrPayloadTooLarge — converter 端拒絕超大檔案。 // 對應 HTTP 413 / code "payload_too_large"。 ErrPayloadTooLarge = errors.New("conversion: payload too large") // ErrConverterUnavailable — converter 5xx / network 持續失敗。 // 對應 HTTP 502 / code "converter_unavailable"。 ErrConverterUnavailable = errors.New("conversion: converter unavailable") // ErrFAAUnavailable — converter 端對 FAA 推送 NEF 失敗(converter 回 502 file_gateway_unavailable)。 // // Phase 0.8b v0.6(T3)後語意調整:visionA 端不再直接呼叫 FAA(ADR-016 撤回), // 此 sentinel 改由 `converter_client.go` 的 promote response mapping 使用 —— // 當 converter promote 內部 PUT FAA 失敗時,converter 回 502 `file_gateway_unavailable`, // visionA-backend 透傳成 `ErrFAAUnavailable` 給 handler 對外 502 + `faa_unavailable`。 // // 與 ErrConverterUnavailable 區分: // - ErrConverterUnavailable:converter scheduler 本身不可達 / 5xx(visionA → converter 失敗) // - ErrFAAUnavailable:converter 可達、但 converter → FAA push 失敗(運維告警打 FAA team) // // 對應 HTTP 502 / code "faa_unavailable"。 ErrFAAUnavailable = errors.New("conversion: faa unavailable") // Phase 0.8b v0.6 T3 移除(visionA 端不再直接打 FAA、相關 sentinel 不再被觸發): // - ErrFAAFileNotFound — FAA `GET /files/{key}` 404(visionA 端已無此 call path) // - ErrFAAAuthFailed — visionA → FAA API key 401/403(visionA 端已無此 call path) // 取代:FAA 相關失敗模式收斂到 converter 端透傳(converter promote 失敗 → ErrFAAUnavailable)。 // **不重用舊 sentinel name**(同 T3 ErrIDPUnavailable 規範;Phase 1+ 若未來再加 FAA 直連 // 路徑,採新 sentinel name 避免閱讀 git log 時混淆語意)。 // ErrServiceBusy — converter 端回 503 service_busy。 // 對應 HTTP 503 / code "service_busy"。 ErrServiceBusy = errors.New("conversion: service busy") // ErrResultExpired — converter `GET /api/v1/jobs/{id}/result` 回 410(v0.6 新增)。 // // 觸發情境(ADR-016 §1.3 / conversion.md §6): // - job 已 completed,但 converter MinIO 內 NEF 已被 GC(超過 7 天 expires_at) // - user 隔了一週以上才回來按「下載」/「加到模型庫」 // // 對外 HTTP 410 / code "result_expired"。frontend 顯示「轉檔結果已過期,請重新轉檔」 // 並提供重新轉檔 CTA(與 502 converter_unavailable 文字明確區分,給 user 行動指引)。 // // 設計選擇(與 ErrJobNotFound 區分): // - ErrJobNotFound(404):「任務本身不存在」(job_id 從未建立 / converter 端 job record 被 GC) // - ErrResultExpired(410):「任務存在過、有完成過,但結果檔被 GC」(converter 端 job // record 仍可查 status,但 MinIO 物件已被清)— user 角度語意不同(前者是 url // 錯了,後者是過期了),HTTP 410 也明確語意「曾經有、現在永遠沒了」(vs 404「不存在」) // // Phase 0.8b v0.6 conversion (見 ADR-016 §1.3 / conversion.md §6 / api-conversion.md §4) ErrResultExpired = errors.New("conversion: converter result expired") // Phase 0.8b T3 移除(5 個僅 mc_token_client 用的 sentinel,MC 路徑取消後不再有觸發點): // - ErrDownloadTokenFailed — MC delegated token 4xx // - ErrMCTokenUnavailable — MC 5xx / network 持續失敗 // - ErrIDPMisconfigured — MC token endpoint 4xx(client_credentials grant 設定錯誤) // - ErrIDPUnavailable — MC oauth/token 5xx / network 持續失敗 // - ErrServiceClientUnauthorized — visionA → MC 認證失敗(401/403) // 取代:401/403 改 mapping 到 ErrConverterAuthFailed(下方); // converter 端 503 改 mapping 到 ErrConverterUnavailable(converter_client.go mapPromoteError)。 // (Phase 0.8b v0.6 T3 加註:原 v0.4/v0.5 引入的 ErrFAAAuthFailed 也已砍除,見上方說明。) // // **不重用舊 sentinel name**(Phase 1+ 注意):上述 5 個 sentinel name 已從 git history // 中砍除,未來若需要新增 sentinel 不應重用同名(如 `ErrIDPUnavailable`),以免閱讀 // git log / blame / 既有 reference 時混淆語意(同名但對應不同層的失敗模式)。 // 若未來 MC 認證鏈以新樣態回來(例如 ADR-015 §7 選項 B 的「visionA 自簽 HMAC token」), // 採用新 sentinel name(例:`ErrHMACTokenUnavailable` / `ErrHMACSigningFailed`)。 // 對應 T3 Reviewer Minor #M-3 / T4 補註說明(.autoflow/05-implementation/backend/logs/t4-final-*.log) // ErrConverterAuthFailed — visionA-backend → converter 帶的 API key 不對齊 // (converter middleware constant-time compare 失敗 → 401 / 403)。 // // 觸發情境(Phase 0.8b API key 路徑): // - VISIONA_CONVERTER_API_KEY 與 converter 端 CONVERTER_API_KEY 不同步 // (rotate 後一邊還沒換 env、stage / prod env 設錯) // - converter middleware 上線前 visionA 過早部署(converter 還沒驗 key) // // 設計選擇:對外仍 mask 成 converter_unavailable / 502 — 不洩漏「API key 對 / 不對」 // 這個內部運維狀態給 frontend;SRE 從 server log 看到 auth_failed 計數異常 → 檢查 env。 // 與 ErrConverterUnavailable 分開的 sentinel 是為了 log / metric 分桶(運維事件 vs 上游不可達), // 對外的 user-facing message 仍然一樣(避免 social engineering 利用 401 訊號做攻擊)。 // // Phase 0.8b conversion (見 ADR-015 §6 / conversion.md §6 / api-conversion.md) ErrConverterAuthFailed = errors.New("conversion: converter api key auth failed") // Phase 0.8b v0.6 T3 移除:ErrFAAAuthFailed(同上方說明)。 // ErrStorageUnavailable — visionA 自家 storage(local FS / S3)寫入或讀取失敗。 // // 觸發情境: // - PromoteToModels 把 NEF 寫進 visionA storage 失敗(disk full / S3 5xx / 權限錯誤) // - 與 FAA / converter 都無關,純粹是 visionA 自己的 storage 設定問題 // // 對應 HTTP 500 / code "storage_unavailable"。 // // 設計選擇(與 ErrFAAUnavailable 區分): // - storage 失敗 ≠ FAA 失敗。SRE alarm 會打到不同 team;i18n 訊息也不同 // (FAA 對外是 "檔案存取服務暫時無法使用",storage 對外是 "伺服器內部錯誤") // - 對外用 500 而非 502:visionA 自身問題,不是 gateway / upstream 問題 // // Phase 0.8 conversion (見 .autoflow/04-architecture/conversion.md §6 — Reviewer M-1) ErrStorageUnavailable = errors.New("conversion: visionA storage unavailable") // ErrModelStoreUnavailable — visionA 自家 model store(in-memory / Postgres)操作失敗。 // // 觸發情境: // - PromoteToModels 把 model record 寫進 model store 失敗 // (in-memory 永遠不會失敗;未來換 Postgres 時 connection 5xx 才會觸發) // - 與 FAA / converter 都無關,純粹是 visionA 自己的 DB 問題 // // 對應 HTTP 500 / code "model_store_unavailable"。 // // 設計選擇(與 ErrConverterUnavailable 區分):理由同 ErrStorageUnavailable。 // // Phase 0.8 conversion (見 .autoflow/04-architecture/conversion.md §6 — Reviewer M-1) ErrModelStoreUnavailable = errors.New("conversion: visionA model store unavailable") ) // ActiveJobError 是 ErrActiveJobExists 的 wrapped form, // 帶上正在進行中的 job 資訊,給 handler 透傳給 frontend // (前端可顯示「你已有進行中任務(job_id=xxx)」+ 跳轉到該 job 的進度頁)。 // // 用法: // // if errors.Is(err, conversion.ErrActiveJobExists) { // var ae *conversion.ActiveJobError // if errors.As(err, &ae) { // // ae.Job 可用,details 帶給 frontend // } // } type ActiveJobError struct { Job *Job } // Error 實作 error interface。 func (e *ActiveJobError) Error() string { return ErrActiveJobExists.Error() } // Unwrap 讓 errors.Is(err, ErrActiveJobExists) 成立。 func (e *ActiveJobError) Unwrap() error { return ErrActiveJobExists } // ValidationFieldError 是 converter 4xx response 中 details.fields 陣列的單一元素。 // // 對齊 converter openapi.yaml `validation_error` example: // // details.fields: [{ field: "model_id", message: "model_id 範圍必須在 1 ~ 65535" }] // // 之所以用 array 不用 map: // - 對齊 task-scheduler openapi.yaml(POST /api/v1/jobs 400 validation_error 範例) // - 同一個 field 可能有多個錯誤(例如 model_id 同時違反 pattern + range) // // Phase 0.8 conversion (見 .autoflow/04-architecture/conversion.md §6 + api-conversion.md §1) type ValidationFieldError struct { Field string `json:"field"` Message string `json:"message"` } // ConverterValidationError 是 ErrValidationFailed 的 wrapped form, // 帶上 converter 回的欄位錯誤細節(給 frontend 顯示具體哪個欄位錯)。 // // 用法同 ActiveJobError。 // // Phase 0.8 conversion (見 .autoflow/04-architecture/conversion.md §6 + api-conversion.md §1) type ConverterValidationError struct { // Fields 是 converter 4xx response 的 details.fields(若有)。 // 結構對齊 converter openapi.yaml — array of {field, message}。 // converter 4xx 沒有 details.fields 時為 nil(仍視為 validation 錯誤)。 Fields []ValidationFieldError // Message 是 converter error message 原文(不過 frontend,僅供 log)。 Message string } // Error 實作 error interface。 func (e *ConverterValidationError) Error() string { if e.Message != "" { return "conversion: validation failed: " + e.Message } return ErrValidationFailed.Error() } // Unwrap 讓 errors.Is(err, ErrValidationFailed) 成立。 func (e *ConverterValidationError) Unwrap() error { return ErrValidationFailed } // ErrorCode 把 sentinel error 轉成對外的 visionA error code(對齊 api-conversion.md §錯誤碼總覽)。 // // 未匹配的 error 回 "internal_error"(handler 層應 log 完整 error 後回 500)。 func ErrorCode(err error) string { switch { case errors.Is(err, ErrForbidden): return "forbidden" case errors.Is(err, ErrJobNotFound): return "not_found" case errors.Is(err, ErrJobNotCompleted): return "job_not_completed" case errors.Is(err, ErrActiveJobExists): return "active_job_exists" case errors.Is(err, ErrValidationFailed): return "validation_failed" case errors.Is(err, ErrPayloadTooLarge): return "payload_too_large" case errors.Is(err, ErrConverterUnavailable): return "converter_unavailable" case errors.Is(err, ErrFAAUnavailable): // Phase 0.8b v0.6 T3 起:此 sentinel 改由 converter promote 502 file_gateway_unavailable // 透傳(visionA 端不再直接打 FAA);對外 code 仍為 faa_unavailable,給 SRE 區分 // converter 不可達 vs converter 端 push FAA 失敗。 return "faa_unavailable" case errors.Is(err, ErrServiceBusy): return "service_busy" case errors.Is(err, ErrResultExpired): // Phase 0.8b v0.6:converter `GET /result` 410;對應 api-conversion.md §錯誤碼總覽 // 新增 `result_expired` code(與 `converter_unavailable` 區分,給 frontend 顯示 // 「轉檔結果已過期,請重新轉檔」CTA)。 return "result_expired" case errors.Is(err, ErrConverterAuthFailed): // Phase 0.8b:對外刻意 mask 成 converter_unavailable(不揭露「API key 不對」內部狀態); // caller 想做精細處理用 errors.Is(err, ErrConverterAuthFailed) 直接判斷(log / metric)。 return "converter_unavailable" case errors.Is(err, ErrStorageUnavailable): return "storage_unavailable" case errors.Is(err, ErrModelStoreUnavailable): return "model_store_unavailable" default: return "internal_error" } } // HTTPStatus 把 sentinel error 轉成對應的 HTTP status code。 // // 未匹配的 error 回 500,handler 層應 log 後再 WriteError。 func HTTPStatus(err error) int { switch { case errors.Is(err, ErrForbidden): return 403 case errors.Is(err, ErrJobNotFound): return 404 case errors.Is(err, ErrJobNotCompleted), errors.Is(err, ErrActiveJobExists): return 409 case errors.Is(err, ErrValidationFailed): return 400 case errors.Is(err, ErrPayloadTooLarge): return 413 case errors.Is(err, ErrConverterUnavailable), errors.Is(err, ErrFAAUnavailable), errors.Is(err, ErrConverterAuthFailed): // Phase 0.8b:API key auth_failed 對外與「服務不可達」同層 502; // 內部 log / metric 才區分(auth_failed = SRE alarm;其他 = 自然 retry) // v0.6 T3 後:ErrFAAFileNotFound / ErrFAAAuthFailed 已砍(visionA 端不再直接打 FAA); // ErrFAAUnavailable 沿用、改由 converter promote 502 file_gateway_unavailable 透傳 return 502 case errors.Is(err, ErrStorageUnavailable), errors.Is(err, ErrModelStoreUnavailable): // visionA 自身基礎設施問題 → 500(不是 502 gateway,因為非 upstream 失敗) return 500 case errors.Is(err, ErrServiceBusy): return 503 case errors.Is(err, ErrResultExpired): // Phase 0.8b v0.6:HTTP 410 Gone — converter result endpoint 過期 // (ADR-016 §1.3)。語意比 404 更明確:「曾經有、現在永遠沒了、不要再 retry」。 return 410 default: return 500 } }