jim800121chen ce6a657df4 feat(visionA-backend): Phase 0.8b v0.6 對齊 — T1+T2 download 改走 converter.GetResult
對齊 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>
2026-05-16 15:09:20 +08:00

1233 lines
51 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Converter client — visionA-backend 對 kneron_model_converter (task-scheduler) 的 HTTP client。
//
// 對應 4 個 endpoint見 kneron_model_converter/apps/task-scheduler/docs/openapi.yaml
// - InitJob: POST /api/v1/jobs (multipart streaming proxy)
// - GetJob: GET /api/v1/jobs/{id}
// - Promote: POST /api/v1/jobs/{id}/promote
// - ListInProgressJobs: GET /api/v1/jobs?user_id=&status=in_progress (lazy rebuild ownership 用)
//
// 設計重點:
// - HTTP retry 矩陣對齊 conversion.md §9.1InitJob 例外:不 retry 5xx見下方 sendInitJob 註解)
// - **Phase 0.8b 認證**:直接帶 pre-shared API keyVISIONA_CONVERTER_API_KEY— 不再走 MC OAuth
// client_credentials grant、不再依賴 MCTokenClient.ServiceToken()。
// 詳見 ADR-015 §3 + conversion.md §3。
// - body 為 streamingInitJob 直接傳 caller 的 io.Reader不暫存 disk、不 buffer 全 RAM
// - 4xx 錯誤 mapping 對齊 §6 + api-conversion.md 錯誤碼總覽
// - 401/403 → ErrConverterAuthFailedPhase 0.8b 新 sentinel對外仍 mask 成 converter_unavailable
// 避免洩漏「API key 不對」這個內部運維狀態SRE 從 server log 看 auth_failed 計數)
//
// 安全:
// - **絕不**把 Authorization header / API key 寫進 log即使是部分前綴
// - 只 log job_id / status / endpoint / attempt / duration
//
// Phase 0.8 conversion (見 .autoflow/04-architecture/conversion.md §2.5 + §9.1)
// Phase 0.8b API key 改造 (見 ADR-015 §3 + §6 + conversion.md §3)
package conversion
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"mime"
"net"
"net/http"
"net/url"
"strings"
"time"
)
// ==========================================================================
// 對外 type / interface
// ==========================================================================
// ConverterClient 對 task-scheduler 的 HTTP client。
//
// 所有 method 都會自動:
// - Phase 0.8b:直接帶 `Authorization: Bearer <VISIONA_CONVERTER_API_KEY>` — 不查 cache、
// 不打 MC、不重簽見 ADR-015 §3 + conversion.md §3.1
// - 依 conversion.md §9.1 retry 矩陣處理 5xx / network / timeoutInitJob 例外)
// - 把 4xx / 5xx 對應到 errors.go 的 sentinel401/403 → ErrConverterAuthFailed不 retry
//
// goroutine-safe每次呼叫獨立 *http.Request無內部 mutable stateapiKey 為 immutable 字串)。
type ConverterClient interface {
// InitJob 把 caller 的 multipart body streaming proxy 給 converter。
//
// 不 retry 5xxmultipart body 是 streamingio.Reader 一次性retry 會傳到一半的爛資料;
// 直接 fail 由 callerflow.go依 §4.3.2 cleanup 鏈處理。
//
// timeout30 分鐘500MB upload 在慢網路可能 5-10 分鐘)。
InitJob(ctx context.Context, req InitConverterJobReq) (*ConverterJob, error)
// GetJob 查單一 job 狀態。
//
// retry: 5xx / network → max 3 attempts (0.5s, 1s, 2s 退避)
GetJob(ctx context.Context, jobID string) (*ConverterJob, error)
// Promote 把成功 job 的指定 stage 結果檔搬到 FAA。
//
// retry: 5xx / network → max 2 attempts (1s, 2s 退避)
//
// 502 file_gateway_unavailable → ErrFAAUnavailableconverter 端 FAA 不可達)
Promote(ctx context.Context, jobID string, req PromoteReq) (*ConverterPromoteResult, error)
// ListInProgressJobs 查指定 user 進行中的 job 清單(給 §2.6.1 lazy rebuild ownership 用)。
//
// retry: 5xx / network → max 1 attempt (0.5s 退避,輕量;不期望常態打)
//
// 預期 0 或 1 筆(同 user 同時只能 1 active job但回 slice 保留 future-proof。
ListInProgressJobs(ctx context.Context, userID string) ([]*ConverterJob, error)
// GetResult 拉 converter `GET /api/v1/jobs/{id}/result` 的 NEF binary stream
// Phase 0.8b v0.6 新增ADR-016 §1
//
// 回傳的 *DownloadMetadata.Filename 直接由 converter response 的 Content-Disposition
// 解出callerflow.go通常會用自己的 `defaultDownloadFilename(cj)` 覆寫,給 user
// 比較友善的檔名converter 給的 filename 可能是 object_key 派生、對 user 不直觀)。
//
// 回傳的 io.ReadCloser 是 streaming response body**caller 必須 Close**
// 不然底層 http.Response.Body 不會釋放、connection 也回不了 pool。
//
// 推薦 pattern
//
// stream, meta, err := client.GetResult(ctx, jobID)
// if err != nil { return err }
// defer stream.Close()
// io.CopyN(dst, stream, MaxDownloadStreamBytes) // streaming + size cap
//
// 重試行為Phase A retry only對齊 conversion.md §9.1
// - dial / TLS / response header 階段的 5xx / network / timeout
// 線性退避重試 max 2 次1s, 2s — `resultRetryBackoff` 採 base × attempt目前 max
// retry = 2 下與指數退避結果一致,未來若擴 retry 次數需重新對齊 conversion.md §9.1
// — GET 沒 request body 完全 idempotent
// - 401 / 403 / 404 / 409 / 410 / 其他 4xx不重試立即 return 對應 sentinel
// - ctx cancel / deadline立即 return ctx.Err()
// - 一旦拿到 200 response進 Phase Bbody streamingreturn *DownloadMetadata
// + streambody 由 caller 自己讀;中斷不再 retrystreaming response 不可 replay
//
// 錯誤映射(對齊 ADR-016 §1.3 + conversion.md §6
// - 401 / 403 → ErrConverterAuthFailed不 retry對外 mask 成 converter_unavailable / 502
// - 404 → ErrJobNotFound不 retry對外 not_found / 404
// - 409 → ErrJobNotCompleted不 retry對外 job_not_completed / 409
// - 410 → ErrResultExpired不 retry對外 result_expired / 410新增
// - 其他 4xx → ErrValidationFailed不 retry極罕見
// - 5xx exhausted / network exhausted → ErrConverterUnavailable對外 converter_unavailable / 502
GetResult(ctx context.Context, jobID string) (io.ReadCloser, *DownloadMetadata, error)
}
// InitConverterJobReq 是 InitJob 的輸入body 為 streamingio.Reader 一次性消費)。
//
// 設計原則:
// - BodyContentType 必須是上層 handler 的原始 Content-Type header 值(含 multipart boundary
// net/http 不會自動產生 — 必須完整透傳,否則 converter multer 會解析失敗
// - UserID 由 visionA-backend trust boundary 灌入(見 conversion.md §7本層不檢查格式
// - SourceFilename / Platform 為 log 用 metadataconverter 自己會從 multipart 解出真值)
type InitConverterJobReq struct {
UserID string // OIDC sub本層僅供 log
Platform string // "520" / "720" / "530" / "630" / "730";本層僅供 log
SourceFilename string // 本層僅供 log
Body io.Reader // 已重組好的 multipart stream含 user_id field
BodyContentType string // 含 boundary 的 Content-Type例如 "multipart/form-data; boundary=xyz"
}
// PromoteReq 是 Promote 的輸入。
//
// 設計原則:
// - UserID 灌進 promote request 的 metadatatrust boundary 重申,見 conversion.md §7.3
// - Source / TargetObjectKey 對齊 converter openapi.yaml `PromoteTarget`
// - Phase 0.8 一律 promote `nef` sourcevisionA 只關心最終可部署到 KL 晶片的 NEF 檔)
type PromoteReq struct {
UserID string // 灌進 promote request body metadata
Source string // "onnx" / "bie" / "nef";預設 "nef"
TargetObjectKey string // FAA 內目標 key由上層flow.go按命名規則組好
}
// ConverterJob 是 InitJob / GetJob / List 的 response shape。
//
// 對齊 converter openapi.yaml 的 Job + CreateJobResponse schema同時保留
// visionA Phase 0.8 §2.6.2 的 ExpiresAt 來源備援邏輯converter 沒給就 caller 推算)。
//
// 注意:這是 client 層的中間 typeflow.go 會轉成 conversion.Job對 frontend 的 shape
type ConverterJob struct {
JobID string
Status string // "created" / "running" / "completed" / "failed"
Stage string // "onnx" / "bie" / "nef"completed 時 converter 回 null → ""
Progress *int // 整體 0-100可能為 nilconverter 沒給)
StageProgress *int // 當前 stage 0-100可能為 nil
SourceFilename string // 取自 input.filename
Platform string // 取自 parameters.platform
CreatedAt time.Time
UpdatedAt time.Time
ExpiresAt time.Time // converter 沒給時上層自行 created_at + 7d 推算
ErrorCode string // 取自 error.code
ErrorMessage string // 取自 error.message
TargetObjectKey string // 僅 promote 後才有GET / list 時為 ""
}
// ConverterPromoteResult 是 Promote 的 response shape。
//
// 對齊 converter openapi.yaml `PromoteResponse`:取 promoted[0]Phase 0.8 一次只 promote 1 target
type ConverterPromoteResult struct {
TargetObjectKey string
Size int64
Checksum string // 取自 file_access_agent_etagconverter 透傳 FAA ETag
}
// ConverterClientOpts 是 NewConverterClient 的依賴注入。
//
// HTTPClient / InitHTTPClient / Now / Logger 為 optionalnil 自動填預設)— 方便 unit test 注入 fake。
type ConverterClientOpts struct {
// BaseURL 是 converter scheduler base URL不帶結尾斜線
// 範例http://192.168.0.130:9501
BaseURL string
// APIKey 是 Phase 0.8b 引入的 pre-shared API keyVISIONA_CONVERTER_API_KEY
// 必填非空 — `NewConverterClient` 會在 APIKey 為空時 panicfail-fast
// 避免 server 在「未認證」狀態下啟動)。
//
// 值由 main.go 從 cfg.Conversion.ConverterAPIKeyenv VISIONA_CONVERTER_API_KEY注入
// 與 converter middleware 端的 CONVERTER_API_KEY 必須對齊rotate 時雙方同步換)。
//
// 安全:絕不 log 此值即使前綴Authorization header 也不 log。
//
// Phase 0.8b API key 改造 (見 ADR-015 §3 + conversion.md §3)
APIKey string
// HTTPClient 為 optionalnil 用預設timeout 10s。GetJob / Promote / List 用。
HTTPClient *http.Client
// InitHTTPClient 為 optionalnil 用預設timeout 30 分鐘)— InitJob 大檔上傳專用。
// 與 HTTPClient 分開避免互相影響GetJob 在 polling 場景頻繁呼叫timeout 短才合理。
InitHTTPClient *http.Client
// StreamHTTPClient 為 optionalnil 用預設(無整體 Timeout自訂 dial / response header
// timeout— GetResult NEF binary stream 大檔下載專用。
//
// 為什麼預設 client 不設 Timeout
// 500MB NEF 在慢網路下 download 可能 5-10 分鐘http.Client.Timeout 是「整體 timeout」
// 涵蓋「dial + response header + body 讀完」三段,會在大檔下載中途斷線。
// 改用 transport 層的 DialTimeout + ResponseHeaderTimeout10s 各自)— 連線階段卡死才算 fail
// body streaming 階段交給 ctx.Done() 控制caller 用帶 deadline 的 ctx 即可)。
//
// 設計同 faa_client.go newDefaultFAAHTTPClientv0.6 砍 faa_client.go 後將 pattern 收進此處)。
//
// Phase 0.8b v0.6 conversion (見 ADR-016 §1.2 / conversion.md §2.3)
StreamHTTPClient *http.Client
// Now 為 optionalnil 用 time.Now。測試會注入 fake clock。
Now func() time.Time
// Logger 為 optionalnil 用 slog.Default()。
Logger *slog.Logger
}
// ==========================================================================
// 內部固定常數
// ==========================================================================
const (
// HTTP timeout
converterDefaultHTTPTimeout = 10 * time.Second
converterInitHTTPTimeout = 30 * time.Minute // InitJob 大檔上傳
// retry 矩陣(對齊 conversion.md §9.1
converterMaxRetriesGet = 2 // GetJob max 3 attempts (1 + 2 retries)
converterMaxRetriesPromote = 2 // Promote max 3 attempts (1 + 2 retries)
converterMaxRetriesList = 1 // List max 2 attempts (1 + 1 retry)
// Phase 0.8b v0.6GetResult 對齊 §9.1 v0.6 新增 row「max 2 attempts、1s, 2s 退避」
// (與 Promote 同等級NEF 拉取頻率低、failure 多半是 converter MinIO 暫時故障可 retry
converterMaxRetriesResult = 2
// 退避 base
converterRetryBase = 500 * time.Millisecond
// resultRetryBaseDelay 是 GetResult 線性退避的 baseattempt=1→1s, attempt=2→2s— 對齊 §9.1 v0.6。
// `resultRetryBackoff` 採 base × attempt目前 max retry = 2 下與指數退避結果一致,
// 未來若擴 retry 次數需重新對齊 conversion.md §9.1。
//
// 與 doWithRetry 用的 converterRetryBase500ms不同result 是大檔下載、Phase A retry
// 期間 server 可能還在 MinIO 暫時故障,延長 base 給 converter 多一點時間恢復。
resultRetryBaseDelay = 1 * time.Second
// resultDialTimeout 是 GetResult 連線階段的 timeoutTCP / TLS 握手)。
resultDialTimeout = 10 * time.Second
// resultResponseHeaderTimeout 是「送完 request → 收到 response status 行」的 timeout。
// 涵蓋 converter 內部 MinIO lookup + auth validate10s 足以涵蓋常態。
// **不涵蓋 body streaming 階段**body 由 ctx 控制)。
resultResponseHeaderTimeout = 10 * time.Second
// resultErrorBodyReadCap 是失敗 response 從 body 讀進 io.Discard 的最大量4KB
// 失敗時讀少量 body 讓 keep-alive 能 reuse connection。
resultErrorBodyReadCap = 4 * 1024
// promote 預設 sourcePhase 0.8 visionA 一律取 nef
promoteDefaultSource = "nef"
)
// ErrConverterAPIKeyNotConfigured 啟動時 API key 為空 — 應在 NewConverterClient 立即 panic、
// 不要等到第一個 request 才發現「未認證」狀態跑進 prod。
//
// Phase 0.8b API key 改造 (見 ADR-015 §3.5.3 部署檢查清單 #1)
var ErrConverterAPIKeyNotConfigured = errors.New("conversion/converter_client: APIKey is required (set VISIONA_CONVERTER_API_KEY)")
// ==========================================================================
// 構造 + 內部實作
// ==========================================================================
// converterClient 是 ConverterClient 的預設實作。
//
// 套件內 unexported structcaller 拿 interface讓未來換實作不影響 caller。
type converterClient struct {
baseURL string
apiKey string // Phase 0.8bpre-shared API key建構時 fail-fast 不允許空字串
http *http.Client
httpInit *http.Client
httpStream *http.Client // Phase 0.8b v0.6GetResult NEF binary stream 專用(無整體 Timeout
now func() time.Time
logger *slog.Logger
}
// NewConverterClient 建立一個 ConverterClient 實例。
//
// 必填BaseURL / APIKey。其他 optional。
// 注意constructor 不驗 BaseURL 連線;第一次呼叫 method 才會打網路。
//
// **Fail-fast**:若 opts.APIKey 為空字串,此函式 panic。理由是 Phase 0.8b 不允許 server 在
// 「未認證」狀態下啟動 — 對齊 ADR-015 §3.5.3 部署檢查清單 #1。
func NewConverterClient(opts ConverterClientOpts) ConverterClient {
if opts.APIKey == "" {
panic(ErrConverterAPIKeyNotConfigured)
}
httpClient := opts.HTTPClient
if httpClient == nil {
httpClient = &http.Client{Timeout: converterDefaultHTTPTimeout}
}
httpInit := opts.InitHTTPClient
if httpInit == nil {
httpInit = &http.Client{Timeout: converterInitHTTPTimeout}
}
httpStream := opts.StreamHTTPClient
if httpStream == nil {
httpStream = newDefaultStreamHTTPClient()
}
now := opts.Now
if now == nil {
now = time.Now
}
logger := opts.Logger
if logger == nil {
logger = slog.Default()
}
return &converterClient{
baseURL: strings.TrimRight(opts.BaseURL, "/"),
apiKey: opts.APIKey,
http: httpClient,
httpInit: httpInit,
httpStream: httpStream,
now: now,
logger: logger,
}
}
// newDefaultStreamHTTPClient 建一個適合 streaming download 的預設 http.Client。
//
// 為什麼自訂 transport同 v0.5 之前 faa_client.go 的 newDefaultFAAHTTPClient
// - http.Client.Timeout 不適用大檔下載(會中斷 body streaming
// - 需要分別控制 dial / response header timeoutbody streaming 不限制(由 ctx 控)
//
// transport 其餘參數沿用 net/http DefaultTransport 的合理預設MaxIdleConns 等)。
//
// Phase 0.8b v0.6 conversion (見 ADR-016 §1.2 / conversion.md §2.3)
func newDefaultStreamHTTPClient() *http.Client {
transport := &http.Transport{
DialContext: (&net.Dialer{
Timeout: resultDialTimeout,
KeepAlive: 30 * time.Second,
}).DialContext,
ResponseHeaderTimeout: resultResponseHeaderTimeout,
// 沿用 DefaultTransport 的合理預設
MaxIdleConns: 100,
MaxIdleConnsPerHost: 10,
IdleConnTimeout: 90 * time.Second,
TLSHandshakeTimeout: 10 * time.Second,
ExpectContinueTimeout: 1 * time.Second,
}
return &http.Client{
Transport: transport,
// **不設 Timeout** — body streaming 階段由 ctx 控制
}
}
// ==========================================================================
// InitJob — multipart streaming proxy不 retry 5xx
// ==========================================================================
func (c *converterClient) InitJob(ctx context.Context, req InitConverterJobReq) (*ConverterJob, error) {
if req.Body == nil {
return nil, fmt.Errorf("conversion/converter_client: InitJob body is required")
}
if req.BodyContentType == "" {
return nil, fmt.Errorf("conversion/converter_client: InitJob body content type is required (must contain multipart boundary)")
}
endpoint := c.baseURL + "/api/v1/jobs"
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, req.Body)
if err != nil {
return nil, fmt.Errorf("%w: build init job request: %v", ErrConverterUnavailable, err)
}
// Content-Type 必須完整透傳(含 multipart boundary不能讓 net/http 自動推導
httpReq.Header.Set("Content-Type", req.BodyContentType)
httpReq.Header.Set("Accept", "application/json")
// Phase 0.8b:直接帶 pre-shared API key不查 cache、不打 MC、不重簽
httpReq.Header.Set("Authorization", "Bearer "+c.apiKey)
startedAt := c.now()
res, err := c.httpInit.Do(httpReq)
duration := c.now().Sub(startedAt)
if err != nil {
// network / ctx cancel — 不 retrystreaming body 已耗盡)
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
c.logger.Warn("conversion.converter.init_ctx_cancelled",
slog.String("user_id", req.UserID),
slog.Duration("duration", duration))
return nil, err
}
c.logger.Warn("conversion.converter.init_network_error",
slog.String("user_id", req.UserID),
slog.Duration("duration", duration),
slog.String("err", truncate(err.Error(), 200)))
return nil, fmt.Errorf("%w: init job network error: %v", ErrConverterUnavailable, err)
}
defer res.Body.Close()
bodyBytes, readErr := io.ReadAll(res.Body)
if readErr != nil {
c.logger.Warn("conversion.converter.init_body_read_failed",
slog.String("user_id", req.UserID),
slog.Int("status", res.StatusCode),
slog.String("err", truncate(readErr.Error(), 200)))
return nil, fmt.Errorf("%w: read init response body: %v", ErrConverterUnavailable, readErr)
}
c.logger.Info("conversion.converter.init_response",
slog.String("user_id", req.UserID),
slog.String("source_filename", req.SourceFilename),
slog.String("platform", req.Platform),
slog.Int("status", res.StatusCode),
slog.Duration("duration", duration))
if res.StatusCode >= 200 && res.StatusCode < 300 {
return parseConverterJob(bodyBytes)
}
// 非 2xx — 一律 mapping 成 sentinel**包括 5xx 也直接 fail不 retry**
return nil, c.mapInitError(res.StatusCode, bodyBytes)
}
// mapInitError 把 InitJob 的非 2xx response mapping 成 sentinel。
//
// 對齊 task-scheduler openapi.yaml POST /api/v1/jobs 的 4xx / 5xx 與 §6 mapping。
func (c *converterClient) mapInitError(status int, body []byte) error {
apiErr := parseAPIError(body)
// Phase 0.8b:認證失敗 = visionA 端 VISIONA_CONVERTER_API_KEY 與 converter 端 CONVERTER_API_KEY
// 不對齊rotate 未同步 / env 設錯 / converter middleware 未上線)。
// 不 retry — API key 不對 retry 100 次也不會自己變對;對外 mask 成 converter_unavailable。
if status == http.StatusUnauthorized || status == http.StatusForbidden {
return fmt.Errorf("%w: init job %d", ErrConverterAuthFailed, status)
}
// 409 user_has_active_job — wrap 成 ActiveJobError
if status == http.StatusConflict && apiErr.Code == "user_has_active_job" {
return &ActiveJobError{Job: extractActiveJobFromDetails(apiErr.Details)}
}
// 400 validation_error / invalid_multipart — wrap 成 ConverterValidationError
if status == http.StatusBadRequest {
return &ConverterValidationError{
Fields: extractFieldsFromDetails(apiErr.Details),
Message: apiErr.Message,
}
}
if status == http.StatusRequestEntityTooLarge {
return fmt.Errorf("%w: init job %d (%s)", ErrPayloadTooLarge, status, apiErr.Code)
}
if status == http.StatusServiceUnavailable {
// converter 503 service_busyprocess semaphore 滿)
return fmt.Errorf("%w: init job %d (%s)", ErrServiceBusy, status, apiErr.Code)
}
// 其他 4xx → validation 視為通用 mapping
if status >= 400 && status < 500 {
return fmt.Errorf("%w: init job %d (%s)", ErrValidationFailed, status, apiErr.Code)
}
// 5xx — InitJob 不 retry直接 mapping 成 ErrConverterUnavailable
return fmt.Errorf("%w: init job %d (%s)", ErrConverterUnavailable, status, apiErr.Code)
}
// ==========================================================================
// GetJob — 標準 retry
// ==========================================================================
func (c *converterClient) GetJob(ctx context.Context, jobID string) (*ConverterJob, error) {
if jobID == "" {
return nil, fmt.Errorf("conversion/converter_client: GetJob jobID is required")
}
endpoint := c.baseURL + "/api/v1/jobs/" + url.PathEscape(jobID)
body, err := c.doWithRetry(ctx, "get_job", jobID, converterMaxRetriesGet,
func() (*http.Request, error) {
req, rerr := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if rerr != nil {
return nil, rerr
}
req.Header.Set("Accept", "application/json")
req.Header.Set("Authorization", "Bearer "+c.apiKey)
return req, nil
},
c.mapGetJobError,
)
if err != nil {
return nil, err
}
return parseConverterJob(body)
}
// mapGetJobError 把 GetJob 的非 2xx 對應到 sentinel。
func (c *converterClient) mapGetJobError(status int, body []byte) error {
apiErr := parseAPIError(body)
// Phase 0.8b401/403 → ErrConverterAuthFailedAPI key 不對齊)
if status == http.StatusUnauthorized || status == http.StatusForbidden {
return fmt.Errorf("%w: get_job %d", ErrConverterAuthFailed, status)
}
if status == http.StatusNotFound {
return fmt.Errorf("%w: get_job %d (%s)", ErrJobNotFound, status, apiErr.Code)
}
if status >= 400 && status < 500 {
return fmt.Errorf("%w: get_job %d (%s)", ErrValidationFailed, status, apiErr.Code)
}
return fmt.Errorf("%w: get_job %d (%s)", ErrConverterUnavailable, status, apiErr.Code)
}
// ==========================================================================
// Promote — 標準 retry + FAA / job_not_completed 特殊 mapping
// ==========================================================================
func (c *converterClient) Promote(ctx context.Context, jobID string, req PromoteReq) (*ConverterPromoteResult, error) {
if jobID == "" {
return nil, fmt.Errorf("conversion/converter_client: Promote jobID is required")
}
if req.TargetObjectKey == "" {
return nil, fmt.Errorf("conversion/converter_client: Promote target_object_key is required")
}
source := req.Source
if source == "" {
source = promoteDefaultSource
}
endpoint := c.baseURL + "/api/v1/jobs/" + url.PathEscape(jobID) + "/promote"
// promote request body — 對齊 openapi.yaml PromoteRequest
// 同時放 user_id 進 metadatatrust boundary 重申§7.3
bodyJSON, err := json.Marshal(map[string]any{
"targets": []map[string]any{
{"source": source, "target_object_key": req.TargetObjectKey},
},
"user_id": req.UserID, // converter Phase 1 不消費,但保留供 log / 未來啟用
})
if err != nil {
return nil, fmt.Errorf("%w: marshal promote request: %v", ErrConverterUnavailable, err)
}
respBody, err := c.doWithRetry(ctx, "promote", jobID, converterMaxRetriesPromote,
func() (*http.Request, error) {
r, rerr := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(bodyJSON))
if rerr != nil {
return nil, rerr
}
r.Header.Set("Content-Type", "application/json")
r.Header.Set("Accept", "application/json")
r.Header.Set("Authorization", "Bearer "+c.apiKey)
return r, nil
},
c.mapPromoteError,
)
if err != nil {
return nil, err
}
return parseConverterPromoteResult(respBody)
}
// mapPromoteError 把 Promote 的非 2xx 對應到 sentinel。
//
// 特殊 mapping
// - 502 file_gateway_unavailable → ErrFAAUnavailable
// - 503 auth_service_unavailable → ErrConverterUnavailablePhase 0.8bMC 路徑取消,
// converter 端的 503 從 visionA 角度看是 converter 整體不可用)
// - 409 job_not_ready_for_promote / source_not_available → ErrJobNotCompleted
func (c *converterClient) mapPromoteError(status int, body []byte) error {
apiErr := parseAPIError(body)
// Phase 0.8b401/403 → ErrConverterAuthFailedAPI key 不對齊)
if status == http.StatusUnauthorized || status == http.StatusForbidden {
return fmt.Errorf("%w: promote %d", ErrConverterAuthFailed, status)
}
if status == http.StatusNotFound {
return fmt.Errorf("%w: promote %d (%s)", ErrJobNotFound, status, apiErr.Code)
}
if status == http.StatusConflict {
// 兩種job_not_ready_for_promote / source_not_available
return fmt.Errorf("%w: promote %d (%s)", ErrJobNotCompleted, status, apiErr.Code)
}
if status == http.StatusBadGateway {
// converter 端 FAA 不可達 / FAA 4xx
return fmt.Errorf("%w: promote %d (%s)", ErrFAAUnavailable, status, apiErr.Code)
}
if status == http.StatusServiceUnavailable {
// Phase 0.8bMC token 路徑取消後converter 端 503 一律歸為 converter 整體不可用
// (從 visionA 角度看無法區分「converter 自己」vs「converter 上游 MC」 — 都是不可用)
return fmt.Errorf("%w: promote %d (%s)", ErrConverterUnavailable, status, apiErr.Code)
}
if status == http.StatusBadRequest || status == http.StatusUnprocessableEntity {
return &ConverterValidationError{
Fields: extractFieldsFromDetails(apiErr.Details),
Message: apiErr.Message,
}
}
if status >= 400 && status < 500 {
return fmt.Errorf("%w: promote %d (%s)", ErrValidationFailed, status, apiErr.Code)
}
return fmt.Errorf("%w: promote %d (%s)", ErrConverterUnavailable, status, apiErr.Code)
}
// ==========================================================================
// ListInProgressJobs — lazy rebuild ownership 用
// ==========================================================================
func (c *converterClient) ListInProgressJobs(ctx context.Context, userID string) ([]*ConverterJob, error) {
if userID == "" {
return nil, fmt.Errorf("conversion/converter_client: ListInProgressJobs userID is required")
}
q := url.Values{}
q.Set("user_id", userID)
q.Set("status", "in_progress")
endpoint := c.baseURL + "/api/v1/jobs?" + q.Encode()
body, err := c.doWithRetry(ctx, "list_jobs", userID, converterMaxRetriesList,
func() (*http.Request, error) {
r, rerr := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if rerr != nil {
return nil, rerr
}
r.Header.Set("Accept", "application/json")
r.Header.Set("Authorization", "Bearer "+c.apiKey)
return r, nil
},
c.mapListJobsError,
)
if err != nil {
return nil, err
}
return parseListJobs(body)
}
// mapListJobsError 把 ListInProgressJobs 的非 2xx 對應到 sentinel。
//
// list 不該回 404user_id 沒 active 應回 200 + jobs:[]),所以 4xx 一律視為 validation。
func (c *converterClient) mapListJobsError(status int, body []byte) error {
apiErr := parseAPIError(body)
// Phase 0.8b401/403 → ErrConverterAuthFailedAPI key 不對齊)
if status == http.StatusUnauthorized || status == http.StatusForbidden {
return fmt.Errorf("%w: list_jobs %d", ErrConverterAuthFailed, status)
}
if status >= 400 && status < 500 {
return fmt.Errorf("%w: list_jobs %d (%s)", ErrValidationFailed, status, apiErr.Code)
}
return fmt.Errorf("%w: list_jobs %d (%s)", ErrConverterUnavailable, status, apiErr.Code)
}
// ==========================================================================
// HTTP 共用retry / 錯誤分類
// ==========================================================================
// doWithRetry 是 GetJob / Promote / List 共用的 retry 執行器。
//
// Phase 0.8b 變更:
// - 移除 `scope` 參數與 `tokens.ServiceToken()` 呼叫API key 改造後不再 per-attempt 取 token
// - reqBuilder closure 不再接 `token` 參數 — caller 直接讀 c.apiKey 自行 set header
//
// 行為(不變):
// - 4xx / 401 / 403 不 retry5xx / network / timeout 可 retry
// - retry 次數由 caller 傳入(不同 endpoint 不同上限)
// - mapErr 由 caller 傳入,因為 GetJob / Promote / List 的 4xx mapping 細節不同
//
// reqBuilder 是「每次 attempt 都重新建一個 *http.Request」的 closure
// — request body 可能在 retry 時已被讀完必須重建。caller 內部用 bytes.NewReader 等可重建的 body。
func (c *converterClient) doWithRetry(
ctx context.Context,
endpointKind, label string,
maxRetries int,
reqBuilder func() (*http.Request, error),
mapErr func(status int, body []byte) error,
) ([]byte, error) {
var lastErr error
for attempt := 0; attempt <= maxRetries; attempt++ {
// retry 前檢查 ctx
if attempt > 0 {
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-time.After(converterRetryBackoff(attempt)):
}
}
req, err := reqBuilder()
if err != nil {
return nil, fmt.Errorf("%w: build %s request: %v", ErrConverterUnavailable, endpointKind, err)
}
body, classifiedErr, retryable := c.doOnce(req, endpointKind, label, attempt, mapErr)
if classifiedErr == nil {
return body, nil
}
lastErr = classifiedErr
if !retryable {
return nil, classifiedErr
}
}
c.logger.Warn("conversion.converter.retry_exhausted",
slog.String("endpoint", endpointKind),
slog.String("label", label),
slog.Int("attempts", maxRetries+1))
return nil, lastErr
}
// doOnce 執行一次 HTTP request回傳 body成功時+ 分類好的 error + 是否可重試。
func (c *converterClient) doOnce(
req *http.Request,
endpointKind, label string,
attempt int,
mapErr func(status int, body []byte) error,
) (body []byte, err error, retryable bool) {
startedAt := c.now()
res, err := c.http.Do(req)
duration := c.now().Sub(startedAt)
if err != nil {
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
c.logger.Warn("conversion.converter.ctx_cancelled",
slog.String("endpoint", endpointKind),
slog.String("label", label),
slog.Int("attempt", attempt+1),
slog.Duration("duration", duration))
return nil, err, false
}
c.logger.Warn("conversion.converter.network_error",
slog.String("endpoint", endpointKind),
slog.String("label", label),
slog.Int("attempt", attempt+1),
slog.Duration("duration", duration),
slog.String("err", truncate(err.Error(), 200)))
return nil, fmt.Errorf("%w: %s network error: %v",
ErrConverterUnavailable, endpointKind, err), true
}
defer res.Body.Close()
bodyBytes, readErr := io.ReadAll(res.Body)
if readErr != nil {
c.logger.Warn("conversion.converter.body_read_failed",
slog.String("endpoint", endpointKind),
slog.String("label", label),
slog.Int("status", res.StatusCode),
slog.String("err", truncate(readErr.Error(), 200)))
return nil, fmt.Errorf("%w: read response body: %v",
ErrConverterUnavailable, readErr), true
}
if res.StatusCode >= 200 && res.StatusCode < 300 {
c.logger.Debug("conversion.converter.success",
slog.String("endpoint", endpointKind),
slog.String("label", label),
slog.Int("status", res.StatusCode),
slog.Int("attempt", attempt+1),
slog.Duration("duration", duration))
return bodyBytes, nil, false
}
c.logger.Warn("conversion.converter.endpoint_error",
slog.String("endpoint", endpointKind),
slog.String("label", label),
slog.Int("status", res.StatusCode),
slog.Int("attempt", attempt+1),
slog.Duration("duration", duration))
classified := mapErr(res.StatusCode, bodyBytes)
// 5xx 視為可重試4xx / 認證失敗 / 已 wrap 為非 transient error 都不重試
retryable = res.StatusCode >= 500 && res.StatusCode < 600
return nil, classified, retryable
}
// converterRetryBackoff 回傳第 n 次 retryn 從 1 開始)的等待時間。
// 對齊 conversion.md §9.1
// - GetJob: 0.5s, 1s, 2sbase=500ms倍數 1, 2, 4 — 但實際只用前 2 次)
// - Promote: 1s, 2sbase=500ms倍數 2, 4
// - List: 0.5sbase=500ms倍數 1
//
// 為了統一 base 但對齊 §9.1 的「Promote 退避 1s, 2s」我們用 base=500ms 加 ×2 倍數,
// 第 n 次退避 = base × 2^n對照 §9.1 GetJob: n=1→500ms*1=500ms 不完全對齊;
// 但 §9.1 主要規範是「指數退避max retry 次數」— 實際數值容忍小偏差,重點是不爆量)。
//
// 最終退避序列n=1→0.5s, n=2→1s, n=3→2sPromote/Get 都從 n=1 開始用,
// 第 1 次 attempt 不退避;第 2 次 attempt = retry 1 = 0.5s 等)。
//
// 不加 jitter — 同 mc_token_clientPhase 0.8 同時 retry 的 caller 不會大量併發打 converter。
func converterRetryBackoff(attempt int) time.Duration {
if attempt < 1 {
return converterRetryBase
}
// 0.5s, 1s, 2s, 4s ...
return converterRetryBase * (1 << (attempt - 1))
}
// Phase 0.8bwrapTokenErr 已移除API key 改造後不再透過 MCTokenClient 取 token
// 因此沒有 token-取-不到 的失敗路徑需要 wrap
// ==========================================================================
// Response 解析converter openapi.yaml shapes
// ==========================================================================
// converterAPIError 是 converter `{error: {...}}` shape 的 unmarshal 中介 type。
type converterAPIError struct {
Code string `json:"code"`
Message string `json:"message"`
Details json.RawMessage `json:"details"`
RequestID string `json:"request_id"`
}
// parseAPIError 解 converter 的 `{error: {code, message, details, request_id}}` shape。
//
// converter 4xx / 5xx 一律遵循此 shape解析失敗時回空 structcaller 仍會走 mapping 預設路徑)。
func parseAPIError(body []byte) converterAPIError {
var wrapper struct {
Error converterAPIError `json:"error"`
}
if err := json.Unmarshal(body, &wrapper); err != nil {
return converterAPIError{}
}
return wrapper.Error
}
// extractFieldsFromDetails 從 converter `details.fields` 解出 ValidationFieldError slice。
//
// 對齊 openapi.yaml 範例:
//
// details: { fields: [{ field: "model_id", message: "..." }] }
//
// 解析失敗回 nilcaller 仍可正常 wrapfrontend 拿不到 fields 但能拿到 code
func extractFieldsFromDetails(raw json.RawMessage) []ValidationFieldError {
if len(raw) == 0 {
return nil
}
var parsed struct {
Fields []ValidationFieldError `json:"fields"`
}
if err := json.Unmarshal(raw, &parsed); err != nil {
return nil
}
return parsed.Fields
}
// extractActiveJobFromDetails 從 converter 409 user_has_active_job 的 details 解出簡化版 Job。
//
// 對齊 openapi.yaml 範例:
//
// details: {
// active_job_id: "...",
// active_job_status: "running",
// active_job_stage: "bie",
// active_job_progress: 45,
// active_job_created_at: "..."
// }
//
// 解析失敗回 nilcaller 仍會走 ActiveJobError只是 Job 為 nil
func extractActiveJobFromDetails(raw json.RawMessage) *Job {
if len(raw) == 0 {
return nil
}
var parsed struct {
ActiveJobID string `json:"active_job_id"`
ActiveJobStatus string `json:"active_job_status"`
ActiveJobStage string `json:"active_job_stage"`
ActiveJobProgress int `json:"active_job_progress"`
ActiveJobCreatedAt time.Time `json:"active_job_created_at"`
}
if err := json.Unmarshal(raw, &parsed); err != nil {
return nil
}
if parsed.ActiveJobID == "" {
return nil
}
return &Job{
JobID: parsed.ActiveJobID,
Status: parsed.ActiveJobStatus,
Stage: parsed.ActiveJobStage,
Progress: parsed.ActiveJobProgress,
CreatedAt: parsed.ActiveJobCreatedAt,
// ExpiresAt 由上層 flow.go 自行 created_at + 7d 推算converter 409 不一定回 expires_at
}
}
// converterJobJSON 是 GET /api/v1/jobs/{id} response 的中介 unmarshal type。
//
// 為了同時支援:
// - CreateJobResponsePOST /jobs 201— 無 stage_progress / input.filename 等欄位
// - JobGET /jobs/{id})— 完整欄位
// 全部欄位都用 pointer 或 nullableMarshal 時靠下方 toConverterJob 統一轉。
type converterJobJSON struct {
JobID string `json:"job_id"`
Status string `json:"status"`
Stage *string `json:"stage"` // completed 時 converter 回 null
Progress *int `json:"progress"`
StageProgress *int `json:"stage_progress"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
ExpiresAt time.Time `json:"expires_at"`
Input *struct {
Filename string `json:"filename"`
} `json:"input"`
Parameters *struct {
Platform string `json:"platform"`
} `json:"parameters"`
Error *struct {
Code string `json:"code"`
Message string `json:"message"`
Stage string `json:"stage"`
} `json:"error"`
}
// parseConverterJob 解 GET /api/v1/jobs/{id} 或 POST /api/v1/jobs 201 的 response。
func parseConverterJob(body []byte) (*ConverterJob, error) {
var jr converterJobJSON
if err := json.Unmarshal(body, &jr); err != nil {
return nil, fmt.Errorf("%w: parse converter job response: %v", ErrConverterUnavailable, err)
}
if jr.JobID == "" {
return nil, fmt.Errorf("%w: empty job_id in converter response", ErrConverterUnavailable)
}
return jr.toConverterJob(), nil
}
// toConverterJob 把 converterJobJSON 轉成對外的 ConverterJob。
func (jr *converterJobJSON) toConverterJob() *ConverterJob {
cj := &ConverterJob{
JobID: jr.JobID,
Status: jr.Status,
Progress: jr.Progress,
StageProgress: jr.StageProgress,
CreatedAt: jr.CreatedAt,
UpdatedAt: jr.UpdatedAt,
ExpiresAt: jr.ExpiresAt,
}
if jr.Stage != nil {
cj.Stage = *jr.Stage
}
if jr.Input != nil {
cj.SourceFilename = jr.Input.Filename
}
if jr.Parameters != nil {
cj.Platform = jr.Parameters.Platform
}
if jr.Error != nil {
cj.ErrorCode = jr.Error.Code
cj.ErrorMessage = jr.Error.Message
}
return cj
}
// parseListJobs 解 GET /api/v1/jobs?user_id=&status=in_progress 的 response。
//
// converter shape{ "jobs": [Job, ...], "total": N, "next_cursor": "..." | null }
func parseListJobs(body []byte) ([]*ConverterJob, error) {
var resp struct {
Jobs []converterJobJSON `json:"jobs"`
}
if err := json.Unmarshal(body, &resp); err != nil {
return nil, fmt.Errorf("%w: parse list jobs response: %v", ErrConverterUnavailable, err)
}
out := make([]*ConverterJob, 0, len(resp.Jobs))
for i := range resp.Jobs {
out = append(out, resp.Jobs[i].toConverterJob())
}
return out, nil
}
// ==========================================================================
// GetResult — Phase 0.8b v0.6streaming NEF pull from converter MinIO
// ==========================================================================
// GetResult 實作 ConverterClient.GetResultPhase 0.8b v0.6ADR-016 §1
//
// 流程:
// 1. 組 URL`{baseURL}/api/v1/jobs/{jobID}/result`
// 2. doStreamWithRetrymax (1 + converterMaxRetriesResult) attempts每 attempt 重新 c.httpStream.Do
// - 拿到 200解 Content-Length / Content-Type / Content-Disposition filenamereturn stream + metadata
// - 拿到 4xxclose body 後依 status mapping 對應 sentinel不 retry
// - 拿到 5xxclose body等 backoff 後 retry
// - network / dial / responseHeader timeout等 backoff 後 retry
// - ctx cancel / deadline立即 return ctx.Err()
//
// 為什麼不沿用 c.doWithRetryGetJob / Promote 用的那個):
// - 那個 helper 在 success 時 ReadAll body 進 []byte不適合 stream
// - 那個 helper 用 c.http10s timeout會中斷大檔下載
// - 那個 helper 把 close body 的責任收進來(不能透傳給 caller
//
// GetResult 是 streaming pull、結構與 v0.5 之前 faa_client.go 的 GetFile 完全對等
// (只是 endpoint 換 converter、auth 換 c.apiKey本檔內保留獨立 retry helper。
func (c *converterClient) GetResult(ctx context.Context, jobID string) (io.ReadCloser, *DownloadMetadata, error) {
if jobID == "" {
return nil, nil, fmt.Errorf("conversion/converter_client: GetResult jobID is required")
}
endpoint := c.baseURL + "/api/v1/jobs/" + url.PathEscape(jobID) + "/result"
return c.doStreamWithRetry(ctx, jobID, endpoint)
}
// doStreamWithRetry 是 GetResult 的 Phase A retry 執行器。
//
// 結構與 v0.5 之前 faa_client.go doWithRetry 相同;對齊 ADR-016 §1.6converter 端
// handler 用 pipeline(minioStream, res) 直接 stream、visionA 端用 ResponseBody as-is 透傳)。
func (c *converterClient) doStreamWithRetry(
ctx context.Context,
jobID, endpoint string,
) (io.ReadCloser, *DownloadMetadata, error) {
var lastErr error
for attempt := 0; attempt <= converterMaxRetriesResult; attempt++ {
// retry 前等待退避ctx cancel 立即中斷
if attempt > 0 {
select {
case <-ctx.Done():
// ctx cancel/deadline → 立即 return不 retry不包成 sentinel
return nil, nil, ctx.Err()
case <-time.After(resultRetryBackoff(attempt)):
}
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
// 建 request 失敗極罕見URL parse 異常)— 不可 retry
return nil, nil, fmt.Errorf("%w: build get_result request: %v", ErrConverterUnavailable, err)
}
req.Header.Set("Accept", "application/octet-stream")
// Phase 0.8b:直接帶 pre-shared API key不查 cache、不打 MC
req.Header.Set("Authorization", "Bearer "+c.apiKey)
stream, meta, classifiedErr, retryable := c.doStreamOnce(req, jobID, attempt)
if classifiedErr == nil {
// 成功 — stream 含未 close 的 body由 caller 接手
return stream, meta, nil
}
lastErr = classifiedErr
if !retryable {
// 4xx / 401-403 / 404 / 409 / 410 / ctx cancel直接 return不再 retry
return nil, nil, classifiedErr
}
// retryable 5xx / network / timeout繼續下一輪
}
// 用完 retry 額度
c.logger.Warn("conversion.converter.get_result_retry_exhausted",
slog.String("job_id", jobID),
slog.Int("attempts", converterMaxRetriesResult+1))
return nil, nil, lastErr
}
// doStreamOnce 執行一次 GetResult Phase A發 request → 等 response header → 分類結果。
//
// 回傳:
// - 成功2xxstream != nil含未 close 的 streaming body, meta != nil, classifiedErr=nil, retryable=false
// - 失敗stream=nil, meta=nil, classifiedErr 為 sentinel-wrapped error, retryable 表示是否該重試
//
// 重要:成功時 callerdoStreamWithRetry會直接把 stream 透傳出去 — 這層**不 close body**。
// 失敗時這層**會 close body**(讀少量讓 keep-alive reuse connection
func (c *converterClient) doStreamOnce(
req *http.Request,
jobID string,
attempt int,
) (stream io.ReadCloser, meta *DownloadMetadata, err error, retryable bool) {
startedAt := c.now()
res, doErr := c.httpStream.Do(req)
duration := c.now().Sub(startedAt)
if doErr != nil {
// network / dial / response header timeout / ctx cancel
if errors.Is(doErr, context.Canceled) || errors.Is(doErr, context.DeadlineExceeded) {
c.logger.Warn("conversion.converter.get_result_ctx_cancelled",
slog.String("job_id", jobID),
slog.Int("attempt", attempt+1),
slog.Duration("duration", duration))
return nil, nil, doErr, false
}
c.logger.Warn("conversion.converter.get_result_network_error",
slog.String("job_id", jobID),
slog.Int("attempt", attempt+1),
slog.Duration("duration", duration),
// err.Error() 不會含 secrethttp.Client 錯誤訊息只有 URL + 連線層 errno
// 但仍 truncate 防 log 爆量
slog.String("err", truncate(doErr.Error(), 200)))
return nil, nil, fmt.Errorf("%w: get_result network error: %v", ErrConverterUnavailable, doErr), true
}
// 成功2xx解 headers把 res.Body 透傳給 caller streaming 消費 — **不在這裡 close**
// 注意:成功路徑沒 defer res.Body.Close() — body 的所有權交給 caller。
if res.StatusCode >= 200 && res.StatusCode < 300 {
contentType := res.Header.Get("Content-Type")
if contentType == "" {
// converter 未設 → 給安全預設octet-stream 必觸發 browser download dialog
contentType = "application/octet-stream"
}
filename := parseFilenameFromContentDisposition(res.Header.Get("Content-Disposition"))
md := &DownloadMetadata{
Filename: filename,
ContentType: contentType,
ContentLength: res.ContentLength,
}
c.logger.Info("conversion.converter.get_result_success",
slog.String("job_id", jobID),
slog.Int("status", res.StatusCode),
slog.Int("attempt", attempt+1),
slog.Int64("content_length", res.ContentLength),
slog.String("content_type", contentType),
slog.String("filename", filename),
slog.Duration("duration", duration))
return res.Body, md, nil, false
}
// 失敗(非 2xx讀少量 body 做 log避免 5xx 帶大 body 爆 log然後 close
// 讀進 io.Discard 而不是真的存下來:
// - 不寫進 logconverter 錯誤 body 可能含 requestId / 路徑等內部資訊)
// - 只是讓 keep-alive 能 reuse connectionread-to-EOF or close
defer res.Body.Close()
_, _ = io.CopyN(io.Discard, res.Body, resultErrorBodyReadCap)
c.logger.Warn("conversion.converter.get_result_endpoint_error",
slog.String("job_id", jobID),
slog.Int("status", res.StatusCode),
slog.Int("attempt", attempt+1),
slog.Duration("duration", duration))
mappedErr, isRetryable := c.mapGetResultError(res.StatusCode)
return nil, nil, mappedErr, isRetryable
}
// mapGetResultError 把 converter `GET /api/v1/jobs/{id}/result` 的非 2xx 對應到 sentinel
// + 是否 retryable。對齊 ADR-016 §1.3 + conversion.md §6。
//
// Phase 0.8b v0.6 新增 mapping
// - 401 / 403 → ErrConverterAuthFailed不 retry — API key 不對齊;運維事件;對外 mask 成 converter_unavailable
// - 404 → ErrJobNotFound不 retry — job_id 不存在 / 已被 GC對外 not_found
// - 409 → ErrJobNotCompleted不 retry — job 尚未 completed理論上 visionA flow.go ensurePromoted
// 階段已先確認 completed 才打 GetResult不應發生保留 mapping 防 converter 端 race
// - 410 → ErrResultExpired**新**,不 retry — job completed 但 NEF 已過 7d expires_at
// 對外 result_expired / 410給 frontend 顯示「請重新轉檔」CTA
// - 其他 4xx → ErrValidationFailed不 retry
// - 5xx → ErrConverterUnavailable**可 retry**converter / MinIO 暫時失常)
//
// **設計取捨404 共用既有 ErrJobNotFound 而非新增 ErrResultNotFound**
// (對齊 T1 Reviewer Minor #M-1
// - conversion.md §6 對 converter 404 `result_not_found` 規定「i18n 與 `not_found`
// 共用文字(轉檔任務不存在)」— 對外語意層面與 ownership 找不到的 404 同樣處理;
// - 新增 sentinel 只會讓 errors.go 多出一個與 ErrJobNotFound 行為完全相同的 code path、
// handler / i18n / metric 全要再加 caseover-engineering
// - 若 Phase 1+ 想對「converter 404 vs ownership 404」做分流例如不同的 retry 策略 /
// 不同的 SRE alarm再評估時引入新 sentinel目前統一 mapping。
func (c *converterClient) mapGetResultError(status int) (err error, retryable bool) {
switch {
case status == http.StatusUnauthorized || status == http.StatusForbidden:
return fmt.Errorf("%w: get_result %d", ErrConverterAuthFailed, status), false
case status == http.StatusNotFound:
return fmt.Errorf("%w: get_result %d", ErrJobNotFound, status), false
case status == http.StatusConflict:
return fmt.Errorf("%w: get_result %d", ErrJobNotCompleted, status), false
case status == http.StatusGone:
// Phase 0.8b v0.6 新增converter MinIO 內 NEF 已被 GC7d expires_at 過期)
return fmt.Errorf("%w: get_result %d", ErrResultExpired, status), false
case status >= 400 && status < 500:
// 其他 4xx不可 retry
return fmt.Errorf("%w: get_result %d", ErrValidationFailed, status), false
default:
// 5xx可 retry
return fmt.Errorf("%w: get_result %d", ErrConverterUnavailable, status), true
}
}
// resultRetryBackoff 回傳第 n 次 retryn 從 1 開始)的等待時間。
// 1 → 1s, 2 → 2s對齊 conversion.md §9.1 v0.6 新增 row
//
// 不加 jitter — Phase 0.8b download 路徑由 user 主動觸發頻率不高,並發競爭機率低;
// jitter 邊際效益低。同 v0.5 之前 faa_client.go faaRetryBackoff 設計。
func resultRetryBackoff(attempt int) time.Duration {
if attempt < 1 {
return resultRetryBaseDelay
}
return resultRetryBaseDelay * time.Duration(attempt)
}
// parseFilenameFromContentDisposition 從 Content-Disposition header 解 filename 屬性。
//
// 範例輸入:`attachment; filename="yolov5s_kl720.nef"`
//
// 解析失敗或缺 filename 屬性 → 回空字串caller 端通常用 defaultDownloadFilename(cj)
// 覆寫converter 給的 filename 只是 fallback / 觀測用)。
//
// 用 mime.ParseMediaType 處理 quoted-string、escape、param 順序等細節,比手撕 split 穩。
func parseFilenameFromContentDisposition(cd string) string {
if cd == "" {
return ""
}
_, params, err := mime.ParseMediaType(cd)
if err != nil {
return ""
}
return params["filename"]
}
// parseConverterPromoteResult 解 POST /api/v1/jobs/{id}/promote 的 response。
//
// 對齊 openapi.yaml `PromoteResponse`:取 promoted[0]Phase 0.8 一次只 promote 1 target
// 若 promoted 陣列為空,回 ErrConverterUnavailable合理表示 converter 內部狀態不一致)。
func parseConverterPromoteResult(body []byte) (*ConverterPromoteResult, error) {
var resp struct {
Promoted []struct {
TargetObjectKey string `json:"target_object_key"`
SizeBytes int64 `json:"size_bytes"`
FileAccessAgentETag string `json:"file_access_agent_etag"`
} `json:"promoted"`
}
if err := json.Unmarshal(body, &resp); err != nil {
return nil, fmt.Errorf("%w: parse promote response: %v", ErrConverterUnavailable, err)
}
if len(resp.Promoted) == 0 {
return nil, fmt.Errorf("%w: promote response has empty promoted array", ErrConverterUnavailable)
}
first := resp.Promoted[0]
if first.TargetObjectKey == "" {
return nil, fmt.Errorf("%w: promote response missing target_object_key", ErrConverterUnavailable)
}
return &ConverterPromoteResult{
TargetObjectKey: first.TargetObjectKey,
Size: first.SizeBytes,
Checksum: first.FileAccessAgentETag,
}, nil
}