fix(image): add response-header timeout and idempotent retry

Give the image client a configurable ResponseHeaderTimeout (default 4m,
capped at the overall timeout) so a stalled upstream fails fast, and
retry such timeouts when an idempotency key is set so a slow-but-safe
request can recover instead of terminating. Raise the Next.js API proxy
maxDuration to 1200s to accommodate long generations.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-08 10:56:42 +08:00
parent 836de5b597
commit 1a1c0a6544
5 changed files with 79 additions and 18 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
import type { NextRequest } from "next/server"; import type { NextRequest } from "next/server";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
export const maxDuration = 120; export const maxDuration = 1200;
export const runtime = "nodejs"; export const runtime = "nodejs";
const apiBase = () => process.env.NEXT_PUBLIC_API_PROXY ?? "http://localhost:8888"; const apiBase = () => process.env.NEXT_PUBLIC_API_PROXY ?? "http://localhost:8888";
+1
View File
@@ -128,6 +128,7 @@ type ImageGenerationConfig struct {
APIKeyEnv string `json:",default=GPT_IMAGE_API_KEY"` APIKeyEnv string `json:",default=GPT_IMAGE_API_KEY"`
InputImageTransport string `json:",default=file"` InputImageTransport string `json:",default=file"`
TimeoutSeconds int `json:",default=600"` TimeoutSeconds int `json:",default=600"`
ResponseHeaderTimeoutSeconds int `json:",default=240"`
} }
type TextExtractionConfig struct { type TextExtractionConfig struct {
@@ -26,6 +26,7 @@ import (
const ( const (
defaultImageGenerationTimeout = 10 * time.Minute defaultImageGenerationTimeout = 10 * time.Minute
defaultImageGenerationResponseHeaderTimeout = 4 * time.Minute
maxImageGenerationTimeout = 10 * time.Minute maxImageGenerationTimeout = 10 * time.Minute
) )
@@ -51,6 +52,7 @@ type ImageClientOptions struct {
APIKey string APIKey string
InputImageTransport string InputImageTransport string
TimeoutSeconds int TimeoutSeconds int
ResponseHeaderTimeoutSeconds int
} }
type ImageClient struct { type ImageClient struct {
@@ -71,6 +73,13 @@ func NewImageClient(opts ImageClientOptions) *ImageClient {
if timeout > maxImageGenerationTimeout { if timeout > maxImageGenerationTimeout {
timeout = maxImageGenerationTimeout timeout = maxImageGenerationTimeout
} }
responseHeaderTimeout := time.Duration(opts.ResponseHeaderTimeoutSeconds) * time.Second
if responseHeaderTimeout <= 0 {
responseHeaderTimeout = defaultImageGenerationResponseHeaderTimeout
}
if responseHeaderTimeout > timeout {
responseHeaderTimeout = timeout
}
return &ImageClient{ return &ImageClient{
baseURL: strings.TrimRight(opts.BaseURL, "/"), baseURL: strings.TrimRight(opts.BaseURL, "/"),
@@ -83,6 +92,7 @@ func NewImageClient(opts ImageClientOptions) *ImageClient {
DialContext: (&net.Dialer{Timeout: 30 * time.Second, KeepAlive: 30 * time.Second}).DialContext, DialContext: (&net.Dialer{Timeout: 30 * time.Second, KeepAlive: 30 * time.Second}).DialContext,
DisableKeepAlives: true, DisableKeepAlives: true,
ForceAttemptHTTP2: false, ForceAttemptHTTP2: false,
ResponseHeaderTimeout: responseHeaderTimeout,
TLSHandshakeTimeout: 10 * time.Second, TLSHandshakeTimeout: 10 * time.Second,
ExpectContinueTimeout: time.Second, ExpectContinueTimeout: time.Second,
}, },
@@ -147,9 +157,13 @@ func (c *ImageClient) Generate(ctx context.Context, prompt string, opts ImageReq
) )
return image, nil return image, nil
} }
if ctx.Err() != nil {
return GeneratedImage{}, ctx.Err()
}
lastErr = err lastErr = err
retryable := isRetryableImageGenerationError(err) timeoutRetryable := idempotencyKey != "" && isImageGenerationTimeoutError(err)
terminal := isImageGenerationTerminalError(err) retryable := isRetryableImageGenerationError(err) || timeoutRetryable
terminal := isImageGenerationTerminalError(err) && !timeoutRetryable
logx.Errorf( logx.Errorf(
"gpt image generation failed model=%s size=%s count=%d prompt_runes=%d attempt=%d elapsed=%s timeout=%s retryable=%t terminal=%t idempotency_key=%s err=%v", "gpt image generation failed model=%s size=%s count=%d prompt_runes=%d attempt=%d elapsed=%s timeout=%s retryable=%t terminal=%t idempotency_key=%s err=%v",
model, model,
@@ -537,6 +551,13 @@ func isImageGenerationTerminalError(err error) bool {
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
return true return true
} }
return isImageGenerationTimeoutError(err)
}
func isImageGenerationTimeoutError(err error) bool {
if err == nil {
return false
}
var netErr net.Error var netErr net.Error
if errors.As(err, &netErr) && netErr.Timeout() { if errors.As(err, &netErr) && netErr.Timeout() {
return true return true
@@ -347,3 +347,41 @@ func TestImageClientDoesNotRetryRequestTimeout(t *testing.T) {
t.Fatalf("expected timeout to fail fast without retry, got %d calls", calls) t.Fatalf("expected timeout to fail fast without retry, got %d calls", calls)
} }
} }
func TestImageClientRetriesIdempotentResponseHeaderTimeout(t *testing.T) {
calls := 0
var keys []string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls++
keys = append(keys, r.Header.Get("Idempotency-Key"))
if calls == 1 {
time.Sleep(80 * time.Millisecond)
return
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"data":[{"url":"https://example.com/recovered.png"}]}`))
}))
defer server.Close()
client := NewImageClient(ImageClientOptions{BaseURL: server.URL, APIKey: "test-key"})
transport, ok := client.client.Transport.(*http.Transport)
if !ok {
t.Fatalf("expected http transport, got %T", client.client.Transport)
}
transport.ResponseHeaderTimeout = 20 * time.Millisecond
client.client.Timeout = time.Second
image, err := client.Generate(context.Background(), "create an image", ImageRequestOptions{Model: "gpt-image-2", IdempotencyKey: "same-generation"})
if err != nil {
t.Fatal(err)
}
if image.URL != "https://example.com/recovered.png" {
t.Fatalf("expected recovered image, got %#v", image)
}
if calls != 2 {
t.Fatalf("expected one retry after timeout, got %d calls", calls)
}
if len(keys) != 2 || keys[0] != "same-generation" || keys[1] != "same-generation" {
t.Fatalf("expected retry to keep idempotency key, got %#v", keys)
}
}
+1
View File
@@ -309,6 +309,7 @@ func newImageGenerator(c config.Config) piagent.ImageGenerator {
APIKey: apiKey, APIKey: apiKey,
InputImageTransport: opts.InputImageTransport, InputImageTransport: opts.InputImageTransport,
TimeoutSeconds: opts.TimeoutSeconds, TimeoutSeconds: opts.TimeoutSeconds,
ResponseHeaderTimeoutSeconds: opts.ResponseHeaderTimeoutSeconds,
}) })
if client == nil { if client == nil {
return nil return nil