jim800121chen 86b7175649 feat(visionA-backend): Phase 0.8b 步驟 2 — visionA → converter / FAA 改 API key 認證
對齊 ADR-015:visionA backend 從 OAuth client_credentials 改 pre-shared API key 服務間認證。Phase 0.8 stage e2e 撞 4 個 blocker(MC scope 沒註冊 / converter image 舊版 / converter 缺 env / FAA 不確定)後,使用者拍板 1:1 internal trust 用 OAuth 過度設計,改 API key。

實作(5 個增量 task,T1-T5 全綠 + Reviewer 5 輪 + final cross-task review):

T1 config + env:
- ConversionConfig 新增 ConverterAPIKey / FAAAPIKey 欄位
- Enabled() 改判定 4 欄位齊全(含兩個 API key)
- .env*.example 移除 OIDC service client / OIDC tenant / FAA delegated TTL env、新增 API key env
- TenantID / DelegatedTTLSeconds T1 暫留、T5 整批清

T2 client 改造:
- converter_client / faa_client 移除 MCTokenClient 依賴
- 直接讀 cfg.Conversion.{Converter,FAA}APIKey、set Authorization: Bearer <key>
- NewConverterClient / NewFAAClient APIKey 為空時 panic(fail-fast,對齊 ADR-015 §3.5.3 #1)
- 新增 ErrConverterAuthFailed / ErrFAAAuthFailed sentinel
- 對外 mask 成 converter_unavailable / faa_unavailable(不洩漏 401 細節,對齊 ADR-015 §3.5.3 #3)

T3 砍 mc_token_client:
- mc_token_client.go (624 行) + mc_token_client_test.go (864 行) 整檔砍
- 砍 5 個僅 mc_token_client 用的 sentinel(ErrServiceClientUnauthorized / ErrMCTokenUnavailable / ErrIDPMisconfigured / ErrIDPUnavailable / ErrDownloadTokenFailed)
- helper(truncate / silentLogger)搬到 util.go / testing_helpers_test.go

T4 flow + handler stream proxy:
- Service interface DownloadRedirectURL → DownloadStream(ctx) (io.ReadCloser, *DownloadMetadata, error)
- flow.DownloadStream 用 faa.GetFile 直接 stream NEF binary(取代 MC delegated token + 302)
- handler conversionDownloadHandler 改 io.Copy + Content-Type/Disposition/Cache-Control header
- 新增 sanitizeDownloadFilename helper 防 HTTP header injection
- 跨 package handler test (conversion_test.go) 改測 ErrFAAUnavailable + 補 *_AuthFailed 對稱 test

T5 wire 切換 + cleanup:
- main.go 砍 mcTokenClient wire、改 APIKey 注入、startup log 用 *_api_key_set boolean(不印 key)
- ConverterClient/FAAClient struct Tokens 欄位移除
- mc_token_stub.go (T4 過渡期) 整檔砍
- ConversionConfig TenantID / DelegatedTTLSeconds 欄位移除
- e2e_test.go TestConversionE2E_Download302Redirect 改寫為 TestConversionE2E_DownloadStream
- 補 MaxDownloadStreamBytes = 1 GiB size cap(io.CopyN,T4 reviewer Minor M-1)
- escapeObjectKeyPath Phase 1+ 預留 godoc + //nolint:unused(T4 reviewer Minor M-2)
- conversion.md §4.1 line 502 filename 來源描述歧義修訂(T4 reviewer Minor M-3,由 architect 處理)
- conversion_e2e_test.go 檔頭 docstring 更新(final reviewer Minor #1)

驗證:
- go build ./... exit 0
- go test -race -count=3 ./... 17 packages 全綠
- ADR-015 §6 砍除清單 100% cover(reviewer 獨立 grep 確認 MC chain / TenantID / DelegatedTTLSeconds 全清)
- ADR-015 §3.5.3 部署檢查清單 visionA 範圍 4/4 達成(fail-fast / mask / 不印 token / placeholder env)

不動:
- OIDCConfig.ServiceClientID/Secret 欄位保留(使用者拍板 backward compat)
- user login OIDC 完全不動

下一步:
- 步驟 4 — converter scheduler middleware 改 API key(jimchen 跨 repo,ADR-015 §3.5.1 Go snippet)
- 步驟 5 — FAA middleware 改 API key(warrenchen 跨 repo,ADR-015 §3.5.2 C# snippet)
- 步驟 6 — stage redeploy + e2e 完整測試

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 09:45:45 +08:00

479 lines
21 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.

// FAA client — visionA-backend 對 File Access Agent 的 server-to-server HTTP client。
//
// Phase 0.8 只用 GET /files/{object_key}(給 promote-to-models 流程從 FAA pull NEF 用)。
// 其他 endpointPUT / DELETE / HEAD / metadata目前 visionA 不需要,未來再補。
//
// 設計要點:
// - **Phase 0.8b 認證**:直接帶 `Authorization: Bearer <VISIONA_FAA_API_KEY>`pre-shared
// API key不再透過 MC OAuth client_credentials grant、不再依賴 MCTokenClient.ServiceToken()。
// 詳見 ADR-015 §3 + conversion.md §3。
// - **回 streaming body**io.ReadCloser— 不 io.ReadAll避免 500MB NEF 全進 RAM
// - **Phase A retry**dial → 拿到 response header 之間的 5xx / network / timeout 失敗
// 依 §9.1 指數退避重試 max 2 次1s, 2s。一旦拿到 200 response進 Phase B
// streaming body 給 caller這層責任就結束 — body 中斷由 caller 處理(不可 replay
// 詳見下方 GetFile doc comment 的「Phase A vs Phase B retry」段。
// - 4xx → 對應 sentinel401/403 → ErrFAAAuthFailed404 → ErrFAAFileNotFound
// 其他 4xx → ErrFAAUnavailable避免新增更多 sentinel
//
// 與 InitJob 的對比(為什麼 InitJob 不 retry 但 GetFile retry
// - InitJobmultipart **request body** 是 streamingio.Reader 來自上游 c.Body
// 一旦 http.Client.Do 開始送 request bodyio.Reader 已被消費retry 無法 rewind →
// 從第一次 attempt 起就「不可重試」。
// - GetFileGET 沒有 request bodyrequest 完全 idempotentretry window 涵蓋
// dial → 拿到 response headerPhase A。Phase A 結束後200 已到response body
// 才是「不可 replay」的 streaming但那不在本層責任範圍 — 本層拿到 200 就 return *FAAFile。
//
// 安全:
// - **絕不**寫 Authorization header / API key / response body 進 log
// - object_key 過長時截斷(避免 log 膨脹FAA object_key 由 visionA 內部組,不含 user 敏感資訊)
//
// Phase 0.8 conversion (見 .autoflow/04-architecture/conversion.md §2.3 / §2.6 / §9.1)
// Phase 0.8b API key 改造 (見 ADR-015 §3 + §6 + conversion.md §3)
package conversion
import (
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"io"
"log/slog"
"net"
"net/http"
"net/url"
"strings"
"time"
)
// ==========================================================================
// 對外 type / interface
// ==========================================================================
// FAAClient 對 File Access Agent 的 server-to-server client。
//
// goroutine-safe每次呼叫獨立 *http.Request無內部 mutable stateapiKey 為 immutable 字串)。
type FAAClient interface {
// GetFile 從 FAA pull 一個 objectserver-to-serverPhase 0.8b 用 pre-shared API key
//
// 回傳 *FAAFile.Body 是 streaming bodyio.ReadCloser**caller 必須 Close**
// 不然底層 http.Response.Body 不會釋放、connection 也回不了 poolgoroutine + fd leak
// 推薦 pattern
//
// file, err := faa.GetFile(ctx, key)
// if err != nil { return err }
// defer file.Body.Close()
// _, err = io.Copy(dst, file.Body) // streaming 寫進 visionA storage
//
// 重試行為Phase A retry only對齊 §9.1
// - dial / TLS / response header 階段的 5xx / network / timeout
// 指數退避重試 max 2 次1s, 2s— GET 沒 request body 完全 idempotent可放心 retry
// - 401 / 403 / 404 / 其他 4xx不重試立即 return 對應 sentinel
// - ctx cancel / deadline立即 return ctx.Err()(即使在 retry sleep 中也立即中斷)
// - 一旦拿到 200 response進 Phase Breturn *FAAFilebody 由 caller 自己讀;
// caller 在讀 body 時遇到網路中斷不再重試streaming response 不可 replay
//
// 錯誤映射(對齊 conversion.md §6 + errors.go
// - ctx cancel/deadline → 透傳 ctx.Err不包成 sentinel
// - 401 / 403 → ErrFAAAuthFailedPhase 0.8b 新 sentinel對外 mask 成 faa_unavailable/502
// - 404 → ErrFAAFileNotFound對外 faa_unavailable/502
// - 其他 4xx / 5xx exhausted / network exhausted → ErrFAAUnavailable對外 faa_unavailable/502
GetFile(ctx context.Context, objectKey string) (*FAAFile, error)
}
// FAAFile 是 GetFile 成功回傳的 streaming response。
//
// **caller 必須 Body.Close()**(即使中途 error也應 defer Close
type FAAFile struct {
// Body 是 streaming response bodycaller 用 io.Copy 等方式 streaming 消費。
Body io.ReadCloser
// ContentLength 對應 FAA response 的 Content-Length header。
// 若 FAA 走 chunked transfer 沒帶這個 header值為 -1net/http 慣例)。
ContentLength int64
// ContentType 對應 FAA response 的 Content-Type header如 "application/octet-stream")。
ContentType string
// ETag 對應 FAA response 的 ETag headerFAA 端取自 storage adapter
// 若 FAA 沒帶,為空字串。
ETag string
}
// FAAClientOpts 是 NewFAAClient 的依賴注入。
//
// HTTPClient / Now / Logger 為 optionalnil 自動填預設)— 方便 unit test 注入 fake。
type FAAClientOpts struct {
// BaseURL 是 FAA base URL不帶結尾斜線
// 範例http://192.168.0.130:5081
BaseURL string
// APIKey 是 Phase 0.8b 引入的 pre-shared API keyVISIONA_FAA_API_KEY
// 必填非空 — `NewFAAClient` 會在 APIKey 為空時 panicfail-fast
// 避免 server 在「未認證」狀態下啟動)。
//
// 值由 main.go 從 cfg.Conversion.FAAAPIKeyenv VISIONA_FAA_API_KEY注入
// 與 FAA middleware 端的 FAA_API_KEY 必須對齊rotate 時雙方同步換FAA 端由 warrenchen 維護)。
//
// 安全:絕不 log 此值即使前綴Authorization header 也不 log。
//
// Phase 0.8b API key 改造 (見 ADR-015 §3 + conversion.md §3)
APIKey string
// HTTPClient 為 optionalnil 用預設(含 dial / response header timeout但無整體 timeout
// 測試會注入 httptest.Server.Client()。
//
// 為什麼預設 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 即可)。
HTTPClient *http.Client
// Now 為 optionalnil 用 time.Now。測試會注入 fake clock。
Now func() time.Time
// Logger 為 optionalnil 用 slog.Default()。
Logger *slog.Logger
}
// ==========================================================================
// 內部固定常數
// ==========================================================================
const (
// faaDialTimeout 是 dial 階段的 timeout連 TCP / TLS 握手)。
// 連線一直建不起來通常是路由問題10s 已足夠;超過視為 FAA 不可達。
faaDialTimeout = 10 * time.Second
// faaResponseHeaderTimeout 是「送完 request → 收到 response status 行」的 timeout。
// 這段是 server-side 處理時間FAA 找檔、auth validate10s 對小檔 metadata 階段夠寬鬆。
// 注意:這個 timeout **不涵蓋 body streaming 階段**body streaming 由 ctx 控制)。
faaResponseHeaderTimeout = 10 * time.Second
// faaMaxRetries 是 Phase A 5xx / network / timeout 的最大重試次數(不含第一次)。
// 對齊 conversion.md §9.1FAA GET /files/{key} max 2 retries1s, 2s
faaMaxRetries = 2
// faaRetryBaseDelay 是指數退避的 base1s, 2s
faaRetryBaseDelay = 1 * time.Second
// objectKeyHashLen 是 log 中 object_key 的截短後 hash 長度(前 16 hex chars
objectKeyHashLen = 16
// faaErrorBodyReadCap 是失敗 response 從 body 讀進 io.Discard 的最大量4KB
// 失敗時讀少量 body 主要是讓 keep-alive 能 reuse connection避免空 body 留在 pipe。
faaErrorBodyReadCap = 4 * 1024
)
// faaEndpointKind 是 log / 錯誤分類用的 endpoint 標記(目前只有一個)。
const faaEndpointKind = "faa_get_file"
// ErrFAAAPIKeyNotConfigured 啟動時 API key 為空 — 應在 NewFAAClient 立即 panic、
// 不要等到第一個 request 才發現「未認證」狀態跑進 prod。
//
// Phase 0.8b API key 改造 (見 ADR-015 §3.5.3 部署檢查清單 #1)
var ErrFAAAPIKeyNotConfigured = errors.New("conversion/faa_client: APIKey is required (set VISIONA_FAA_API_KEY)")
// ==========================================================================
// 構造 + 內部實作
// ==========================================================================
// faaClient 是 FAAClient 的預設實作。
//
// 套件內 unexported structcaller 拿 interface讓未來換實作不影響 caller。
type faaClient struct {
baseURL string
apiKey string // Phase 0.8bpre-shared API key建構時 fail-fast 不允許空字串
http *http.Client
now func() time.Time
logger *slog.Logger
}
// NewFAAClient 建立一個 FAAClient 實例。
//
// 必填BaseURL / APIKey。其他 optional。
// 注意constructor 不會驗 BaseURL 連線,第一次 GetFile 才會打網路。
//
// **Fail-fast**:若 opts.APIKey 為空字串,此函式 panic。理由是 Phase 0.8b 不允許 server 在
// 「未認證」狀態下啟動 — 對齊 ADR-015 §3.5.3 部署檢查清單 #1。
//
// `opts.Tokens` 是 Phase 0.8 廢棄欄位(見 FAAClientOpts.Tokens 註解),即使非 nil 也不被
// 內部使用T5 切換 wire 點後從 struct 移除。
func NewFAAClient(opts FAAClientOpts) FAAClient {
if opts.APIKey == "" {
panic(ErrFAAAPIKeyNotConfigured)
}
httpClient := opts.HTTPClient
if httpClient == nil {
httpClient = newDefaultFAAHTTPClient()
}
now := opts.Now
if now == nil {
now = time.Now
}
logger := opts.Logger
if logger == nil {
logger = slog.Default()
}
return &faaClient{
baseURL: strings.TrimRight(opts.BaseURL, "/"),
apiKey: opts.APIKey,
http: httpClient,
now: now,
logger: logger,
}
}
// newDefaultFAAHTTPClient 建一個適合 streaming download 的預設 http.Client。
//
// 為什麼自訂 transport
// - http.Client.Timeout 不適用大檔下載(會中斷 body streaming
// - 需要分別控制 dial / response header timeoutbody streaming 不限制(由 ctx 控)
//
// transport 其餘參數沿用 net/http DefaultTransport 的合理預設MaxIdleConns 等)。
func newDefaultFAAHTTPClient() *http.Client {
transport := &http.Transport{
DialContext: (&net.Dialer{
Timeout: faaDialTimeout,
KeepAlive: 30 * time.Second,
}).DialContext,
ResponseHeaderTimeout: faaResponseHeaderTimeout,
// 沿用 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 控制
}
}
// ==========================================================================
// GetFile — Phase A retryPhase B 不 retry 的 streaming pull
// ==========================================================================
// GetFile 實作 FAAClient.GetFile。
//
// 流程Phase 0.8b
// 1. 組 URL + 建 request直接帶 c.apiKey 進 Authorization header不再透過 MCTokenClient
// 2. doWithRetrymax (1 + faaMaxRetries) attempts每 attempt 重新 c.http.Do
// - 拿到 200直接 return *FAAFile不 close body
// - 拿到 4xxclose body 後依 status mapping 對應 sentinel不 retry
// - 拿到 5xxclose body等 backoff 後 retry
// - network / dial / responseHeader timeout等 backoff 後 retry
// - ctx cancel / deadline立即 return ctx.Err()
func (c *faaClient) GetFile(ctx context.Context, objectKey string) (*FAAFile, error) {
if objectKey == "" {
return nil, fmt.Errorf("conversion/faa_client: object_key is required")
}
keyHash := hashObjectKey(objectKey)
// 1. 組 endpoint。注意 FAA 的 object_key 可能含路徑分隔符(如 "tenant/jobs/abc/output.nef")—
// 用 ResolveReference 處理net/http 內部會做 path escape避免 "../" 等問題。
endpoint, err := c.buildFileURL(objectKey)
if err != nil {
return nil, fmt.Errorf("%w: build faa url: %v", ErrFAAUnavailable, err)
}
// 2. 進 retry loopPhase A onlyapiKey 在 doWithRetry 內 set header
return c.doWithRetry(ctx, keyHash, endpoint)
}
// doWithRetry 是 GetFile 的 Phase A retry 執行器。
//
// Phase 0.8b 變更:
// - 不再接收 token 參數API key 改造後 c.apiKey 直接 set header
//
// 與 converter_client.doWithRetry 結構類似,差異:
// - 成功路徑回傳 *FAAFile含未 close 的 streaming body不是 []byte
// - 沒有「每次 attempt 重新建 request」需求 — GET 沒 bodyrequest 物件可重用,
// 但為了讓 ctx-aware 行為一致ctx cancel 後不重用舊 request這裡每次都新建一個
func (c *faaClient) doWithRetry(
ctx context.Context,
keyHash, endpoint string,
) (*FAAFile, error) {
var lastErr error
for attempt := 0; attempt <= faaMaxRetries; attempt++ {
// retry 前等待退避ctx cancel 立即中斷
if attempt > 0 {
select {
case <-ctx.Done():
// ctx cancel/deadline → 立即 return不 retry不包成 sentinel
return nil, ctx.Err()
case <-time.After(faaRetryBackoff(attempt)):
}
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
// 建 request 失敗極罕見URL parse 異常)— 不可 retry
return nil, fmt.Errorf("%w: build faa request: %v", ErrFAAUnavailable, err)
}
req.Header.Set("Accept", "application/octet-stream")
// Phase 0.8b:直接帶 pre-shared API key不查 cache、不打 MC
req.Header.Set("Authorization", "Bearer "+c.apiKey)
file, classifiedErr, retryable := c.doOnce(req, keyHash, attempt)
if classifiedErr == nil {
// 成功 — file 含未 close 的 body由 caller 接手
return file, nil
}
lastErr = classifiedErr
if !retryable {
// 4xx / 401-403 / 404 / ctx cancel直接 return不再 retry
return nil, classifiedErr
}
// retryable 5xx / network / timeout繼續下一輪
}
// 用完 retry 額度
c.logger.Warn("conversion.faa.retry_exhausted",
slog.String("endpoint", faaEndpointKind),
slog.String("object_key_hash", keyHash),
slog.Int("attempts", faaMaxRetries+1))
return nil, lastErr
}
// doOnce 執行一次 Phase A發 request → 等 response header → 分類結果。
//
// 回傳:
// - 成功2xxfile != nil含未 close 的 streaming body, classifiedErr=nil, retryable=false
// - 失敗file=nil, classifiedErr 為 sentinel-wrapped error, retryable 表示是否該重試
//
// 重要:成功時 callerdoWithRetry會直接把 file 透傳出去 — 這層**不 close body**。
// 失敗時這層**會 close body**(讀少量讓 keep-alive reuse connection
func (c *faaClient) doOnce(
req *http.Request,
keyHash string,
attempt int,
) (file *FAAFile, err error, retryable bool) {
startedAt := c.now()
res, doErr := c.http.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.faa.ctx_cancelled",
slog.String("endpoint", faaEndpointKind),
slog.String("object_key_hash", keyHash),
slog.Int("attempt", attempt+1),
slog.Duration("duration", duration))
return nil, doErr, false
}
c.logger.Warn("conversion.faa.network_error",
slog.String("endpoint", faaEndpointKind),
slog.String("object_key_hash", keyHash),
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, fmt.Errorf("%w: faa network error: %v", ErrFAAUnavailable, doErr), true
}
// 成功2xx直接把 res.Body 透傳給 caller streaming 消費 — **不在這裡 close**
// 注意:成功路徑沒 defer res.Body.Close() — body 的所有權交給 *FAAFile.Body。
if res.StatusCode >= 200 && res.StatusCode < 300 {
c.logger.Info("conversion.faa.get_success",
slog.String("endpoint", faaEndpointKind),
slog.String("object_key_hash", keyHash),
slog.Int("status", res.StatusCode),
slog.Int("attempt", attempt+1),
slog.Int64("content_length", res.ContentLength),
slog.Duration("duration", duration))
return &FAAFile{
Body: res.Body, // caller 責任 Close
ContentLength: res.ContentLength,
ContentType: res.Header.Get("Content-Type"),
ETag: res.Header.Get("ETag"),
}, nil, false
}
// 失敗(非 2xx讀少量 body 做 log避免 5xx 帶大 body 爆 log然後 close
// 讀進 io.Discard 而不是真的存下來:
// - 不寫進 logFAA 錯誤 body 可能含 requestId / 路徑等內部資訊)
// - 只是讓 keep-alive 能 reuse connectionread-to-EOF or close
defer res.Body.Close()
_, _ = io.CopyN(io.Discard, res.Body, faaErrorBodyReadCap)
c.logger.Warn("conversion.faa.endpoint_error",
slog.String("endpoint", faaEndpointKind),
slog.String("object_key_hash", keyHash),
slog.Int("status", res.StatusCode),
slog.Int("attempt", attempt+1),
slog.Duration("duration", duration))
mappedErr, isRetryable := c.mapGetFileError(res.StatusCode)
return nil, mappedErr, isRetryable
}
// mapGetFileError 把 FAA `GET /files/{key}` 的非 2xx 對應到 sentinel + 是否 retryable。
//
// Phase 0.8b 對齊 ADR-015 §3.5.2 FAA middleware
// - 401 unauthorized → ErrFAAAuthFailed不 retry — API key 不對齊;運維事件)
// - 403 forbidden → ErrFAAAuthFailed不 retry
//
// 其他 mapping不變
// - 404 file_not_found → ErrFAAFileNotFound不 retry — object 不存在)
// - 400 invalid_object_key → ErrFAAUnavailable不 retry — visionA 端 object_key 命名 bug
// - 其他 4xx → ErrFAAUnavailable不 retry
// - 5xx → ErrFAAUnavailable**可 retry**FAA / 下游 storage 暫時失常)
func (c *faaClient) mapGetFileError(status int) (err error, retryable bool) {
switch {
case status == http.StatusUnauthorized || status == http.StatusForbidden:
return fmt.Errorf("%w: faa get file %d", ErrFAAAuthFailed, status), false
case status == http.StatusNotFound:
return fmt.Errorf("%w: faa get file %d", ErrFAAFileNotFound, status), false
case status >= 400 && status < 500:
// 400 / 其他 4xx不可 retry
return fmt.Errorf("%w: faa get file %d", ErrFAAUnavailable, status), false
default:
// 5xx可 retry
return fmt.Errorf("%w: faa get file %d", ErrFAAUnavailable, status), true
}
}
// faaRetryBackoff 回傳第 n 次 retryn 從 1 開始)的等待時間。
// 1 → 1s, 2 → 2s對齊 conversion.md §9.1
//
// 不加 jitter — Phase 0.8 同時打 FAA 的 caller 數量有限promote-to-models 流程是
// 序列式 per-job 觸發併發競爭機率低jitter 的邊際效益低。
func faaRetryBackoff(attempt int) time.Duration {
if attempt < 1 {
return faaRetryBaseDelay
}
return faaRetryBaseDelay * time.Duration(attempt)
}
// buildFileURL 用 url.Parse + ResolveReference 組 GET /files/{objectKey} 的完整 URL。
//
// 為什麼用 ResolveReference 而不是 string concat
// - object_key 可能含路徑分隔符("tenant/jobs/abc/output.nef"
// - 直接 concat 容易踩 trailing-slash / encoding 雷
// - net/url 會做必要的 percent-escape保留 '/' 為 path separator
func (c *faaClient) buildFileURL(objectKey string) (string, error) {
base, err := url.Parse(c.baseURL)
if err != nil {
return "", fmt.Errorf("parse base url: %w", err)
}
// 用 url.URL{Path: ...} 避免手動 escapenet/url 會處理 path encoding。
// 注意base.Path 可能為空或結尾帶 "/"ResolveReference 會處理。
ref := &url.URL{Path: "/files/" + objectKey}
return base.ResolveReference(ref).String(), nil
}
// hashObjectKey 把 object_key 算 SHA-256 後取前 16 hex chars當 log 用的穩定 hash。
//
// 為什麼不直接 log object_key
// - object_key 可能含路徑("tenant/jobs/uuid/output.nef")— 過長
// - 目前 visionA 的 object_key 不直接含 user 敏感資訊,但保險起見統一 hash
// - 16 chars hex64-bit對 visionA 內部 job 數量來說碰撞機率極低,足以追蹤單一 request
func hashObjectKey(objectKey string) string {
sum := sha256.Sum256([]byte(objectKey))
return hex.EncodeToString(sum[:])[:objectKeyHashLen]
}