對齊 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>
479 lines
21 KiB
Go
479 lines
21 KiB
Go
// 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 用)。
|
||
// 其他 endpoint(PUT / 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 → 對應 sentinel(401/403 → ErrFAAAuthFailed;404 → ErrFAAFileNotFound;
|
||
// 其他 4xx → ErrFAAUnavailable,避免新增更多 sentinel)
|
||
//
|
||
// 與 InitJob 的對比(為什麼 InitJob 不 retry 但 GetFile retry):
|
||
// - InitJob:multipart **request body** 是 streaming(io.Reader 來自上游 c.Body);
|
||
// 一旦 http.Client.Do 開始送 request body,io.Reader 已被消費,retry 無法 rewind →
|
||
// 從第一次 attempt 起就「不可重試」。
|
||
// - GetFile:GET 沒有 request body,request 完全 idempotent;retry window 涵蓋
|
||
// dial → 拿到 response header(Phase 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 state(apiKey 為 immutable 字串)。
|
||
type FAAClient interface {
|
||
// GetFile 從 FAA pull 一個 object(server-to-server,Phase 0.8b 用 pre-shared API key)。
|
||
//
|
||
// 回傳 *FAAFile.Body 是 streaming body(io.ReadCloser);**caller 必須 Close**,
|
||
// 不然底層 http.Response.Body 不會釋放、connection 也回不了 pool(goroutine + 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 B):return *FAAFile,body 由 caller 自己讀;
|
||
// caller 在讀 body 時遇到網路中斷不再重試(streaming response 不可 replay)
|
||
//
|
||
// 錯誤映射(對齊 conversion.md §6 + errors.go):
|
||
// - ctx cancel/deadline → 透傳 ctx.Err(不包成 sentinel)
|
||
// - 401 / 403 → ErrFAAAuthFailed(Phase 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 body;caller 用 io.Copy 等方式 streaming 消費。
|
||
Body io.ReadCloser
|
||
|
||
// ContentLength 對應 FAA response 的 Content-Length header。
|
||
// 若 FAA 走 chunked transfer 沒帶這個 header,值為 -1(net/http 慣例)。
|
||
ContentLength int64
|
||
|
||
// ContentType 對應 FAA response 的 Content-Type header(如 "application/octet-stream")。
|
||
ContentType string
|
||
|
||
// ETag 對應 FAA response 的 ETag header(FAA 端取自 storage adapter)。
|
||
// 若 FAA 沒帶,為空字串。
|
||
ETag string
|
||
}
|
||
|
||
// FAAClientOpts 是 NewFAAClient 的依賴注入。
|
||
//
|
||
// HTTPClient / Now / Logger 為 optional(nil 自動填預設)— 方便 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 key(VISIONA_FAA_API_KEY)。
|
||
// 必填非空 — `NewFAAClient` 會在 APIKey 為空時 panic(fail-fast,
|
||
// 避免 server 在「未認證」狀態下啟動)。
|
||
//
|
||
// 值由 main.go 從 cfg.Conversion.FAAAPIKey(env 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 為 optional;nil 用預設(含 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 + ResponseHeaderTimeout(10s 各自)— 連線階段卡死才算 fail,
|
||
// body streaming 階段交給 ctx.Done() 控制(caller 用帶 deadline 的 ctx 即可)。
|
||
HTTPClient *http.Client
|
||
|
||
// Now 為 optional;nil 用 time.Now。測試會注入 fake clock。
|
||
Now func() time.Time
|
||
|
||
// Logger 為 optional;nil 用 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 validate);10s 對小檔 metadata 階段夠寬鬆。
|
||
// 注意:這個 timeout **不涵蓋 body streaming 階段**(body streaming 由 ctx 控制)。
|
||
faaResponseHeaderTimeout = 10 * time.Second
|
||
|
||
// faaMaxRetries 是 Phase A 5xx / network / timeout 的最大重試次數(不含第一次)。
|
||
// 對齊 conversion.md §9.1:FAA GET /files/{key} max 2 retries(1s, 2s)。
|
||
faaMaxRetries = 2
|
||
|
||
// faaRetryBaseDelay 是指數退避的 base(1s, 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 struct(caller 拿 interface),讓未來換實作不影響 caller。
|
||
type faaClient struct {
|
||
baseURL string
|
||
apiKey string // Phase 0.8b:pre-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 timeout,body 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 retry,Phase B 不 retry 的 streaming pull
|
||
// ==========================================================================
|
||
|
||
// GetFile 實作 FAAClient.GetFile。
|
||
//
|
||
// 流程(Phase 0.8b):
|
||
// 1. 組 URL + 建 request(直接帶 c.apiKey 進 Authorization header;不再透過 MCTokenClient)
|
||
// 2. doWithRetry:max (1 + faaMaxRetries) attempts;每 attempt 重新 c.http.Do
|
||
// - 拿到 200:直接 return *FAAFile(不 close body)
|
||
// - 拿到 4xx:close body 後依 status mapping 對應 sentinel,不 retry
|
||
// - 拿到 5xx:close 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 loop(Phase A only);apiKey 在 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 沒 body,request 物件可重用,
|
||
// 但為了讓 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 → 分類結果。
|
||
//
|
||
// 回傳:
|
||
// - 成功(2xx):file != nil(含未 close 的 streaming body), classifiedErr=nil, retryable=false
|
||
// - 失敗:file=nil, classifiedErr 為 sentinel-wrapped error, retryable 表示是否該重試
|
||
//
|
||
// 重要:成功時 caller(doWithRetry)會直接把 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() 不會含 secret(http.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 而不是真的存下來:
|
||
// - 不寫進 log(FAA 錯誤 body 可能含 requestId / 路徑等內部資訊)
|
||
// - 只是讓 keep-alive 能 reuse connection(read-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 次 retry(n 從 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: ...} 避免手動 escape;net/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 hex(64-bit)對 visionA 內部 job 數量來說碰撞機率極低,足以追蹤單一 request
|
||
func hashObjectKey(objectKey string) string {
|
||
sum := sha256.Sum256([]byte(objectKey))
|
||
return hex.EncodeToString(sum[:])[:objectKeyHashLen]
|
||
}
|