// 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 — FAA 5xx / network 持續失敗。 // 對應 HTTP 502 / code "faa_unavailable"。 ErrFAAUnavailable = errors.New("conversion: faa unavailable") // ErrFAAFileNotFound — FAA 回 404(指定 object_key 不存在)。 // 觸發情境:promote-to-models 流程 promoted 後 FAA pull 卻找不到檔(罕見: // converter promote 才剛寫 FAA、應立即可見)— 可能 FAA 端 GC、或 object_key 命名邏輯有 bug。 // 對應 HTTP 502 / code "faa_unavailable"(對外仍視為 FAA 不可用,避免揭露內部 object key 細節)。 // caller(flow.go)可用 errors.Is(err, ErrFAAFileNotFound) 做精細處理(log / metric)。 // // Phase 0.8 conversion (見 .autoflow/04-architecture/conversion.md §2.6 + §9.2) ErrFAAFileNotFound = errors.New("conversion: faa file not found") // ErrDownloadTokenFailed — MC 換 delegated token 4xx 失敗(設定問題)。 // 對應 HTTP 502 / code "download_token_failed"。 ErrDownloadTokenFailed = errors.New("conversion: download token failed") // ErrMCTokenUnavailable — MC 5xx / network 持續失敗。 // 對應 HTTP 502 / code "mc_token_unavailable"。 ErrMCTokenUnavailable = errors.New("conversion: mc token unavailable") // ErrIDPMisconfigured — MC token endpoint 4xx(client_credentials grant 設定錯誤)。 // 對應 HTTP 500 / code "idp_misconfigured"。 ErrIDPMisconfigured = errors.New("conversion: idp misconfigured") // ErrIDPUnavailable — MC oauth/token 5xx / network 持續失敗。 // 對應 HTTP 503 / code "idp_unavailable"。 ErrIDPUnavailable = errors.New("conversion: idp unavailable") // ErrServiceBusy — converter 端回 503 service_busy。 // 對應 HTTP 503 / code "service_busy"。 ErrServiceBusy = errors.New("conversion: service busy") // ErrServiceClientUnauthorized — visionA-backend 對 MC 認證失敗(401 / 403)。 // // 觸發情境: // - VISIONA_OIDC_SERVICE_CLIENT_ID / SECRET 設定錯誤(典型) // - MC 端 client 被 revoke / 停用 // - client 沒有對應 scope 的權限 // // 設計選擇:與 ErrIDPMisconfigured 分開的 sentinel,給 mc_token_client 內部 caller // 可以做更精細的處理(例如 401 時主動 invalidate cache),但對外 ErrorCode/HTTPStatus // 都對應到 idp_misconfigured / 500(fail-fast,避免半設定狀態跑進 production)。 // // Phase 0.8 conversion (見 .autoflow/04-architecture/conversion.md §5.2) ErrServiceClientUnauthorized = errors.New("conversion: service client unauthorized") // 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, ErrFAAFileNotFound): // 對外仍視為 faa_unavailable,避免揭露 object_key 不存在的內部細節。 // caller 想做精細處理用 errors.Is(err, ErrFAAFileNotFound) 直接判斷。 return "faa_unavailable" case errors.Is(err, ErrFAAUnavailable): return "faa_unavailable" case errors.Is(err, ErrDownloadTokenFailed): return "download_token_failed" case errors.Is(err, ErrMCTokenUnavailable): return "mc_token_unavailable" case errors.Is(err, ErrIDPMisconfigured): return "idp_misconfigured" case errors.Is(err, ErrIDPUnavailable): return "idp_unavailable" case errors.Is(err, ErrServiceBusy): return "service_busy" case errors.Is(err, ErrServiceClientUnauthorized): // 對外仍透過 idp_misconfigured 呈現(避免 leak「我們的 client_secret 過期」這種內部狀態); // caller 想做精細處理用 errors.Is(err, ErrServiceClientUnauthorized) 直接判斷。 return "idp_misconfigured" 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, ErrFAAFileNotFound), errors.Is(err, ErrDownloadTokenFailed), errors.Is(err, ErrMCTokenUnavailable): return 502 case errors.Is(err, ErrIDPMisconfigured), errors.Is(err, ErrServiceClientUnauthorized): return 500 case errors.Is(err, ErrStorageUnavailable), errors.Is(err, ErrModelStoreUnavailable): // visionA 自身基礎設施問題 → 500(不是 502 gateway,因為非 upstream 失敗) return 500 case errors.Is(err, ErrIDPUnavailable), errors.Is(err, ErrServiceBusy): return 503 default: return 500 } }