對齊 ADR-016:visionA download 不再經 FAA delegated token、改用 converter GET /api/v1/jobs/{id}/result 中轉。
T1 — converter_client.go 加 GetResult method:
- 新增 GetResult(ctx, jobID) (io.ReadCloser, *DownloadMetadata, error)
- 新增 ErrResultExpired sentinel + ErrorCode("result_expired") + HTTPStatus 410 mapping
- 獨立 StreamHTTPClient (無 timeout / dial+response header timeout) 給 streaming 大檔
- doStreamWithRetry / doStreamOnce / mapGetResultError / resultRetryBackoff helpers
- parseFilenameFromContentDisposition (RFC 5987 quoted/unquoted/encoded)
- 9 個 GetResult test + 6 個 parseFilename sub-test
- Reviewer 0 Critical / 0 Major / 3 Minor (M-1/M-2/M-3 全部 T2 順手修)
T2 — flow.go + e2e 改造:
- DownloadStream / PromoteToModels 移除 f.faa.GetFile(...) 改 f.converter.GetResult(ctx, jobID)
- filename 仍由 defaultDownloadFilename(cj) 覆寫 (visionA source-of-truth)
- 8 個 flow_test 既有 test 改寫 + 2 個改名 (FAA → Converter) + 2 個 410 透傳 test 新增
- e2e mock converter 加 GET /api/v1/jobs/{id}/result endpoint + 3 helper + 6 斷言更新 (含 negative: FAA 0 命中 / converter /result ≥1 命中)
- T1 reviewer 3 個 Minor 全處理 (mapGetResultError 設計取捨 godoc / 指數退避→線性退避 / 401+403 mask 驗證)
- 保留 faa FAAClient 欄位 + FlowOpts.FAA 必填 (T3 才砍 faa_client.go 整檔)
T2 修補 (architect + backend 平行):
- M-1 conversion.go Service interface DownloadStream/PromoteToModels godoc 對齊 v0.6 (從 flow.go layer 搬上來)
- M-2 conversion.md v0.6 → v0.6.1 — §2.5 ensurePromoted cache 描述「sync.Map cache」改為「Phase 0.8 簡化 (不實作 cache)」+ 4 簡化理由 + 3 Phase 1+ 升級選項 (in-memory / DB / model store 推論);連動修改 line 169 / 300 / 1187 cross-reference
- 3 Minor + 2 Suggestion 順手做 (resultRetryBaseDelay godoc / fixture 註解過渡狀態 / e2e route table 4→5 / flow.go struct T3 預期清單 / e2e negative assertion 強化)
驗證:
- go build ./... exit 0
- go test -race -count=3 ./... 17 packages 全綠
- Reviewer 5 軸 (v0.6-t1-review + v0.6-t2-review + v0.6-t2-fix-review) 全 ✅ 通過
對齊 ADR-016 §1 / conversion.md v0.6.1 §2.5 §4.1 / api-conversion.md v0.6 §4 / oidc-tdd.md v0.4 §13.1.3
下一步:
- T3 砍 faa_client.go + faa_client_test.go + 對應 ErrFAA* sentinel (B 層強制跑 / s-3/s-4/s-5 必補)
- T4 砍 ConversionConfig FAA* 欄位 + main.go wire 點 + .env*.example
- T5 main.go wire 點全切 + e2e regression 防護
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
311 lines
15 KiB
Go
311 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 — 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")
|
||
|
||
// 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 / ErrFAAAuthFailed(下方);
|
||
// converter 端 503 改 mapping 到 ErrConverterUnavailable(converter_client.go mapPromoteError)。
|
||
//
|
||
// **不重用舊 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")
|
||
|
||
// ErrFAAAuthFailed — visionA-backend → FAA 帶的 API key 不對齊(FAA middleware 401 / 403)。
|
||
//
|
||
// 觸發情境(Phase 0.8b API key 路徑):
|
||
// - VISIONA_FAA_API_KEY 與 FAA 端 FAA_API_KEY 不同步(warrenchen 跨 repo 維護)
|
||
// - FAA middleware 上線前 visionA 過早部署
|
||
//
|
||
// 設計選擇:與 ErrConverterAuthFailed 對稱、與 ErrFAAUnavailable 分開(同樣 mask 成
|
||
// faa_unavailable / 502 對外、區分只在 server log)。
|
||
//
|
||
// Phase 0.8b conversion (見 ADR-015 §6 / conversion.md §6 / api-conversion.md)
|
||
ErrFAAAuthFailed = errors.New("conversion: faa api key auth failed")
|
||
|
||
// 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, 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, ErrFAAAuthFailed):
|
||
// Phase 0.8b:對外刻意 mask 成 faa_unavailable,理由同上。
|
||
return "faa_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, ErrFAAFileNotFound),
|
||
errors.Is(err, ErrConverterAuthFailed),
|
||
errors.Is(err, ErrFAAAuthFailed):
|
||
// Phase 0.8b:API key auth_failed 對外與「服務不可達」同層 502;
|
||
// 內部 log / metric 才區分(auth_failed = SRE alarm;其他 = 自然 retry)
|
||
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
|
||
}
|
||
}
|