對齊 ADR-016:visionA backend 不再直連 FAA、download 改走 converter GetResult。T3 砍除 v0.5 階段為 FAA delegated token 路線留的 faa_client.go 整檔 + 對應 sentinel + flow / e2e 殘留。 砍除: - internal/conversion/faa_client.go(整檔) - internal/conversion/faa_client_test.go(整檔) - errors.go: ErrFAAFileNotFound + ErrFAAAuthFailed 2 sentinel(+ ErrorCode/HTTPStatus mapping) - flow.go: faa FAAClient 欄位 + FlowOpts.FAA 必填 + a-h T3 預期清單 godoc - flow_test.go: flowStubFAA struct + newFlowStubFAA helper + fixture.faa - internal/api/conversion_test.go: TestConversion_Download_FAAAuthFailed - cmd/api-server/main.go: NewFAAClient wire + FAA: faaAPIClient field 保留: - ErrFAAUnavailable(converter promote 仍 PUT FAA、502 透傳路徑需要) - hashObjectKey helper 搬到 util.go(ownership 仍用) - e2e mockFAA 精簡為 regression-only(保留 negative assertion: FAA 0 命中)— reviewer 推薦雙層防護 新增(T3 必補,T1/T2 reviewer 累積): - s-3 TestDownloadStream_ConverterValidationFailed_Propagation(converter 4xx fallback → ErrValidationFailed 透傳) - s-4 TestPromoteToModels_StorageError_StreamClosed(instrumented stream wrapper 驗 fd leak 防護) - s-5 TestParseFilenameFromContentDisposition 9 個 sub-case(3 RFC 5987 + 5 hostile-input + 1 empty quoted) 發現:Go stdlib 自動 percent-decode RFC 5987 並寫入 params["filename"]、RFC 5987 優先於 ASCII filename T3 review M-1 修補(commit 內含): - internal/api/conversion.go:51,56 godoc + 501 user-facing message 從「FAA_BASE_URL」改為「VISIONA_CONVERTER_BASE_URL + VISIONA_CONVERTER_API_KEY」 - 對齊 ADR-016 visionA 端不再有 FAA 直連設計 驗證: - B 層 verification 強制跑(reviewer 規定 T3 不接受暫緩): * 跨檔 grep: MC chain 0 / FAA functional refs 0 / TenantID 0 * API contract test: TestConversionE2E_DownloadStream 6 斷言含 FAA negative * 安全 manual review: path traversal / unbounded read / secret in log / error mask 4 項 - go build ./... exit 0 - go test -race -count=3 ./... 17 packages 全綠 - Reviewer 5 軸(v0.6-t3-review)⚠️→ ✅ 通過(M-1 已修) a-h 8 條清單 100% 達成(逐條 grep 驗收);mockFAA 選方案 1(保留 + negative assertion)— 雙層防護。 下一步: - T4 砍 ConversionConfig.FAAAPIKey/FAABaseURL + load.go env 讀取 + .env*.example + m-2 i18n dead case 一併 - T5 main.go startup log 整理 + e2e regression 防護 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
306 lines
15 KiB
Go
306 lines
15 KiB
Go
// 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
|
||
}
|
||
}
|