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:
@@ -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";
|
||||||
|
|||||||
@@ -123,11 +123,12 @@ type AgentConfig struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type ImageGenerationConfig struct {
|
type ImageGenerationConfig struct {
|
||||||
BaseURL string `json:",optional"`
|
BaseURL string `json:",optional"`
|
||||||
APIKey string `json:",optional"`
|
APIKey string `json:",optional"`
|
||||||
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 {
|
||||||
|
|||||||
@@ -25,8 +25,9 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
defaultImageGenerationTimeout = 10 * time.Minute
|
defaultImageGenerationTimeout = 10 * time.Minute
|
||||||
maxImageGenerationTimeout = 10 * time.Minute
|
defaultImageGenerationResponseHeaderTimeout = 4 * time.Minute
|
||||||
|
maxImageGenerationTimeout = 10 * time.Minute
|
||||||
)
|
)
|
||||||
|
|
||||||
type ImageGenerator interface {
|
type ImageGenerator interface {
|
||||||
@@ -47,10 +48,11 @@ type ImageRequestOptions struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type ImageClientOptions struct {
|
type ImageClientOptions struct {
|
||||||
BaseURL string
|
BaseURL string
|
||||||
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -305,10 +305,11 @@ func newImageGenerator(c config.Config) piagent.ImageGenerator {
|
|||||||
opts := c.Agent.Image
|
opts := c.Agent.Image
|
||||||
apiKey := resolveImageAPIKey(opts)
|
apiKey := resolveImageAPIKey(opts)
|
||||||
client := piagent.NewImageClient(piagent.ImageClientOptions{
|
client := piagent.NewImageClient(piagent.ImageClientOptions{
|
||||||
BaseURL: opts.BaseURL,
|
BaseURL: opts.BaseURL,
|
||||||
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
|
||||||
|
|||||||
Reference in New Issue
Block a user