Phase 0.8 把 kneron_model_converter 的轉檔功能整合進 visionA Cloud。
visionA backend 當 streaming proxy(upload)+ delegated download token broker(download)+
ownership trust boundary,converter / FAA / MC 三方零修改。
新增 internal/conversion/ 套件(8 個檔,~10,000 行 prod+test,117+ test cases,race -count=3 全綠):
- conversion.go:Service interface 5 method、Job/PromoteResult/InitJobInput types
- errors.go:13+ sentinel errors + ErrorCode/HTTPStatus mapping,對齊 conversion.md §6
- mc_token_client.go:service-to-service token (client_credentials grant) + DCL cache
(exp - 15s 重取,per-scope cache),IssueDelegatedDownload(MC delegated download token)
錯誤分 idp_misconfigured (4xx) / idp_unavailable (5xx) / download_token_failed / mc_token_unavailable
- converter_client.go:對 converter scheduler 4 method(InitJob multipart streaming /
GetJob / Promote / ListInProgressJobs),InitJob 不 retry 5xx(streaming body 無法 replay)
- faa_client.go:對 FAA GET /files/{key} server-to-server pull,Phase A retry(GET 無 body
可 replay)對齊 §9.1 retry 矩陣,streaming io.ReadCloser 透傳避 OOM
- ownership.go:in-memory job_id → user_id map + per-user mutex 防 thundering herd lazy rebuild
(不同 user 平行 fetch,同 user 100 caller 收斂成 1 次),visionA 重啟靠 converter
ListInProgressJobs(user) 重建
- flow.go:Service interface 整合層(5 method 串接 converter/FAA/MC/ownership)
- InitJob 用 io.Pipe + multipart.Reader/Writer 重組 streaming proxy(黑名單 client user_id
+ 灌入 OIDC sub)
- DownloadRedirectURL 自動觸發 promote(spec §1 Stage 3b),用 ensurePromoted helper
- PromoteToModels 冪等(modelStore.FindBySourceJobID 為 source-of-truth)
- OwnershipMismatch → ErrJobNotFound 不 forbidden(§7.2 防枚舉)
- storage / modelStore 失敗包 ErrStorageUnavailable / ErrModelStoreUnavailable
(視為 visionA 自身 500 而非 502 gateway,SRE alarm 才打對 team)
新增 internal/api/conversion.go(5 endpoint handler + main.go wire):
- POST /api/conversion/init(multipart streaming proxy,不呼叫 c.MultipartForm())
- GET /api/conversion/active(lazy rebuild ownership)
- GET /api/conversion/{job_id}(poll status)
- POST /api/conversion/{job_id}/promote-to-models(FAA pull → models 三段式)
- GET /api/conversion/{job_id}/download(server-side HTTP 302 → FAA,token 不過 frontend
JS,仿 FAA TestSite DownloadFileDirect pattern;Cache-Control: no-store)
5 個 endpoint 全部走 OIDC AuthMiddleware;user_id 從 cookie session 灌(trust boundary),
從不接受 client multipart form / JSON / query 的 user_id。
TestAllAPIEndpointsRequire401WithoutCookie 自動覆蓋新 5 endpoint regression 防呆。
新增 cmd/api-server/conversion_e2e_test.go(4 個 e2e 場景):
- TestConversionE2E_StreamingProxy(10MB body + trust boundary regression)
- TestConversionE2E_LazyRebuildAfterRestart(visionA 重啟仍能 /active)
- TestConversionE2E_Download302Redirect(驗 302 + Location header + token 不在 body)
- TestConversionE2E_ActiveJobConflict(409 + active_job 詳情)
修改 internal/config/{config,load}.go:新增 ConversionConfig 5 欄位
(ConverterBaseURL / FAABaseURL / TenantID / ServiceClientID / ServiceClientSecret)+
Enabled() helper(雙非空判定)。
修改 cmd/api-server/main.go:條件 wire(cfg.Conversion.Enabled() 為 true 才建 client + Service;
否則 Deps.Conversion=nil,handler 自動回 501)。
修改 .env.example:新增 Phase 0.8 區塊註解。
新增 cmd/api-server/conversion_adapters.go:narrow interface adapter(接既有
internal/model.Repository / internal/storage.Store → conversion.ModelStore / Storage,避免 import cycle)。
驗證:go test -race -count=3 ./... 17 packages 全綠 / go vet 0 warning / go build 成功。
對齊文件:
- .autoflow/04-architecture/adr/adr-014-conversion-integration.md
- .autoflow/04-architecture/conversion.md (TDD)
- .autoflow/04-architecture/api/api-conversion.md
- .autoflow/02-prd/features/feature-converter-integration.md
- .autoflow/03-design/wireframes/wireframe-conversion.md
- .autoflow/03-design/flows/flow-conversion.md
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
275 lines
12 KiB
Go
275 lines
12 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")
|
||
|
||
// 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
|
||
}
|
||
}
|