From f9b945e4aae9008860482d8f9ffff6a9e3719c38 Mon Sep 17 00:00:00 2001 From: wangbo Date: Wed, 5 Aug 2026 14:35:54 +0800 Subject: [PATCH] =?UTF-8?q?fix(provider):=20=E7=BB=9F=E4=B8=80=E5=93=8D?= =?UTF-8?q?=E5=BA=94=E4=BD=93=E8=B6=85=E6=97=B6=E9=94=99=E8=AF=AF=E5=88=86?= =?UTF-8?q?=E7=B1=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将收到响应头后在读取 JSON 或 Veo 视频响应体阶段发生的 deadline/网络超时统一归类为 terminal timeout,保留 HTTP 状态、request ID 和 wire 证据,避免继续返回 response_read_error。 验证:go test ./... -count=1;go test ./internal/clients ./internal/runner -count=1;go vet ./internal/clients ./internal/runner;gofmt。 --- apps/api/internal/clients/gemini_veo.go | 5 +++ apps/api/internal/clients/helpers.go | 6 ++++ .../internal/clients/media_timeout_test.go | 35 +++++++++++++++++++ 3 files changed, 46 insertions(+) diff --git a/apps/api/internal/clients/gemini_veo.go b/apps/api/internal/clients/gemini_veo.go index 320d595..55facfc 100644 --- a/apps/api/internal/clients/gemini_veo.go +++ b/apps/api/internal/clients/gemini_veo.go @@ -555,6 +555,11 @@ func (c GeminiClient) geminiVeoDownload(ctx context.Context, request Request, ap } payload, err := io.ReadAll(io.LimitReader(resp.Body, geminiVeoMaxVideoBytes+1)) if err != nil { + if timeoutErr := transportClientError(err); timeoutErr.Code == "timeout" { + timeoutErr.StatusCode = resp.StatusCode + timeoutErr.RequestID = requestIDFromHTTPResponse(resp) + return nil, "", timeoutErr + } return nil, "", &ClientError{Code: "response_read_error", Message: err.Error(), StatusCode: resp.StatusCode, Retryable: true} } if int64(len(payload)) > geminiVeoMaxVideoBytes { diff --git a/apps/api/internal/clients/helpers.go b/apps/api/internal/clients/helpers.go index 4cccc31..15f4c7e 100644 --- a/apps/api/internal/clients/helpers.go +++ b/apps/api/internal/clients/helpers.go @@ -73,6 +73,12 @@ func decodeHTTPResponseForProtocol(resp *http.Response, protocol string) (map[st if len(raw) > 0 { _ = json.Unmarshal(raw, &wire.Body) } + if timeoutErr := transportClientError(readErr); timeoutErr.Code == "timeout" { + timeoutErr.StatusCode = resp.StatusCode + timeoutErr.RequestID = requestIDFromHTTPResponse(resp) + timeoutErr.Wire = wire + return nil, wire, timeoutErr + } return nil, wire, &ClientError{ Code: "response_read_error", Message: readErr.Error(), diff --git a/apps/api/internal/clients/media_timeout_test.go b/apps/api/internal/clients/media_timeout_test.go index 08ee646..153c0ce 100644 --- a/apps/api/internal/clients/media_timeout_test.go +++ b/apps/api/internal/clients/media_timeout_test.go @@ -2,10 +2,26 @@ package clients import ( "context" + "io" + "net/http" "testing" "time" ) +type timeoutResponseBody struct { + read bool +} + +func (body *timeoutResponseBody) Read(buffer []byte) (int, error) { + if body.read { + return 0, context.DeadlineExceeded + } + body.read = true + return copy(buffer, []byte(`{"partial":`)), nil +} + +func (*timeoutResponseBody) Close() error { return nil } + func TestProviderRequestTimeoutUsesMediaDefaults(t *testing.T) { for _, test := range []struct { kind string @@ -29,6 +45,25 @@ func TestTransportTimeoutUsesTerminalTimeoutCode(t *testing.T) { } } +func TestResponseBodyTimeoutUsesTerminalTimeoutCode(t *testing.T) { + response := &http.Response{ + StatusCode: http.StatusOK, + Header: make(http.Header), + Body: io.NopCloser(&timeoutResponseBody{}), + } + _, wire, err := decodeHTTPResponseForProtocol(response, ProtocolOpenAIImages) + if err == nil { + t.Fatal("expected response body timeout") + } + clientErr, ok := err.(*ClientError) + if !ok || clientErr.Code != "timeout" || clientErr.Retryable { + t.Fatalf("response body timeout classification = %#v, want terminal timeout", err) + } + if clientErr.StatusCode != http.StatusOK || clientErr.Wire != wire { + t.Fatalf("response body timeout lost response metadata: %#v", clientErr) + } +} + func TestMediaPollTimeoutsUseTaskDefaultsWithoutPlatformOverride(t *testing.T) { image := Request{Kind: "images.generations", Candidate: storeCandidateWithConfig("", "", nil, nil)} video := Request{Kind: "videos.generations", Candidate: storeCandidateWithConfig("", "", nil, nil)}