diff --git a/frontend/src/app/api/[...path]/route.ts b/frontend/src/app/api/[...path]/route.ts index 97dae30..cb09b15 100644 --- a/frontend/src/app/api/[...path]/route.ts +++ b/frontend/src/app/api/[...path]/route.ts @@ -1,7 +1,7 @@ import type { NextRequest } from "next/server"; export const dynamic = "force-dynamic"; -export const maxDuration = 120; +export const maxDuration = 1200; export const runtime = "nodejs"; const apiBase = () => process.env.NEXT_PUBLIC_API_PROXY ?? "http://localhost:8888"; diff --git a/server/internal/config/config.go b/server/internal/config/config.go index 3929889..f10edc3 100644 --- a/server/internal/config/config.go +++ b/server/internal/config/config.go @@ -123,11 +123,12 @@ type AgentConfig struct { } type ImageGenerationConfig struct { - BaseURL string `json:",optional"` - APIKey string `json:",optional"` - APIKeyEnv string `json:",default=GPT_IMAGE_API_KEY"` - InputImageTransport string `json:",default=file"` - TimeoutSeconds int `json:",default=600"` + BaseURL string `json:",optional"` + APIKey string `json:",optional"` + APIKeyEnv string `json:",default=GPT_IMAGE_API_KEY"` + InputImageTransport string `json:",default=file"` + TimeoutSeconds int `json:",default=600"` + ResponseHeaderTimeoutSeconds int `json:",default=240"` } type TextExtractionConfig struct { diff --git a/server/internal/infrastructure/piagent/image_generation.go b/server/internal/infrastructure/piagent/image_generation.go index 1ee3171..c4e6373 100644 --- a/server/internal/infrastructure/piagent/image_generation.go +++ b/server/internal/infrastructure/piagent/image_generation.go @@ -25,8 +25,9 @@ import ( ) const ( - defaultImageGenerationTimeout = 10 * time.Minute - maxImageGenerationTimeout = 10 * time.Minute + defaultImageGenerationTimeout = 10 * time.Minute + defaultImageGenerationResponseHeaderTimeout = 4 * time.Minute + maxImageGenerationTimeout = 10 * time.Minute ) type ImageGenerator interface { @@ -47,10 +48,11 @@ type ImageRequestOptions struct { } type ImageClientOptions struct { - BaseURL string - APIKey string - InputImageTransport string - TimeoutSeconds int + BaseURL string + APIKey string + InputImageTransport string + TimeoutSeconds int + ResponseHeaderTimeoutSeconds int } type ImageClient struct { @@ -71,6 +73,13 @@ func NewImageClient(opts ImageClientOptions) *ImageClient { if timeout > maxImageGenerationTimeout { timeout = maxImageGenerationTimeout } + responseHeaderTimeout := time.Duration(opts.ResponseHeaderTimeoutSeconds) * time.Second + if responseHeaderTimeout <= 0 { + responseHeaderTimeout = defaultImageGenerationResponseHeaderTimeout + } + if responseHeaderTimeout > timeout { + responseHeaderTimeout = timeout + } return &ImageClient{ 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, DisableKeepAlives: true, ForceAttemptHTTP2: false, + ResponseHeaderTimeout: responseHeaderTimeout, TLSHandshakeTimeout: 10 * time.Second, ExpectContinueTimeout: time.Second, }, @@ -147,9 +157,13 @@ func (c *ImageClient) Generate(ctx context.Context, prompt string, opts ImageReq ) return image, nil } + if ctx.Err() != nil { + return GeneratedImage{}, ctx.Err() + } lastErr = err - retryable := isRetryableImageGenerationError(err) - terminal := isImageGenerationTerminalError(err) + timeoutRetryable := idempotencyKey != "" && isImageGenerationTimeoutError(err) + retryable := isRetryableImageGenerationError(err) || timeoutRetryable + terminal := isImageGenerationTerminalError(err) && !timeoutRetryable 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", model, @@ -537,6 +551,13 @@ func isImageGenerationTerminalError(err error) bool { if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { return true } + return isImageGenerationTimeoutError(err) +} + +func isImageGenerationTimeoutError(err error) bool { + if err == nil { + return false + } var netErr net.Error if errors.As(err, &netErr) && netErr.Timeout() { return true diff --git a/server/internal/infrastructure/piagent/image_generation_test.go b/server/internal/infrastructure/piagent/image_generation_test.go index 17c5cb3..bd40e5f 100644 --- a/server/internal/infrastructure/piagent/image_generation_test.go +++ b/server/internal/infrastructure/piagent/image_generation_test.go @@ -347,3 +347,41 @@ func TestImageClientDoesNotRetryRequestTimeout(t *testing.T) { 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) + } +} diff --git a/server/internal/svc/service_context.go b/server/internal/svc/service_context.go index abeb74e..ae53b5b 100644 --- a/server/internal/svc/service_context.go +++ b/server/internal/svc/service_context.go @@ -305,10 +305,11 @@ func newImageGenerator(c config.Config) piagent.ImageGenerator { opts := c.Agent.Image apiKey := resolveImageAPIKey(opts) client := piagent.NewImageClient(piagent.ImageClientOptions{ - BaseURL: opts.BaseURL, - APIKey: apiKey, - InputImageTransport: opts.InputImageTransport, - TimeoutSeconds: opts.TimeoutSeconds, + BaseURL: opts.BaseURL, + APIKey: apiKey, + InputImageTransport: opts.InputImageTransport, + TimeoutSeconds: opts.TimeoutSeconds, + ResponseHeaderTimeoutSeconds: opts.ResponseHeaderTimeoutSeconds, }) if client == nil { return nil