From 07ea1c7cd40023b4c07d2a3a3b53f8c43807273f Mon Sep 17 00:00:00 2001 From: Adhik Joshi Date: Tue, 22 Sep 2026 23:32:26 +0530 Subject: [PATCH] Fail fast when the API reports a failed generation The API reports a failed generation as HTTP 200 with status "error" and no job id. pollAndDownload only looked for "success", so it went on to poll fetch/ with an empty id: every poll errored, was retried silently, and the command exited 7 after the 5-minute timeout with a blank job id. With --no-wait it printed "Job queued" for a job that never existed. The API is about to answer provider refusals on the generation routes this way too (they were HTTP 500, which the client retried and then reported). pollAndDownload now returns the API's message at once for "error"/"failed", including structured validation messages, and refuses to poll without a job id. The poll loop reuses the same message handling. --- internal/cmd/generate.go | 48 +++++++++++++++++++---- internal/cmd/generate_poll_test.go | 62 ++++++++++++++++++++++++++++++ 2 files changed, 103 insertions(+), 7 deletions(-) create mode 100644 internal/cmd/generate_poll_test.go diff --git a/internal/cmd/generate.go b/internal/cmd/generate.go index 3530e39..7cd3b7b 100644 --- a/internal/cmd/generate.go +++ b/internal/cmd/generate.go @@ -15,8 +15,8 @@ import ( ) var generateCmd = &cobra.Command{ - Use: "generate", - Short: "Generate AI content (image, video, audio, 3D, chat)", + Use: "generate", + Short: "Generate AI content (image, video, audio, 3D, chat)", Aliases: []string{"gen"}, } @@ -54,6 +54,16 @@ func pollAndDownload(cmd *cobra.Command, genType, fetchEndpoint string, result m return handleCompleted(result, download, outputDir, genType) } + // The API reports a failed generation as HTTP 200 with status "error" and no + // job id, so the client sees no error. Polling then asked fetch/ for an + // empty id until the timeout, and --no-wait printed "Job queued" for it. + if status == "error" || status == "failed" { + return generationFailed(result) + } + if requestID == "" { + return fmt.Errorf("the API returned no job id to poll: %s", responseSummary(result)) + } + if noWait { outputResult(result, func() { fmt.Printf("Job queued: %s\n", requestID) @@ -96,11 +106,7 @@ func pollAndDownload(cmd *cobra.Command, genType, fetchEndpoint string, result m case "success": return handleCompleted(fetchResult, download, outputDir, genType) case "error", "failed": - msg := "Generation failed" - if m, ok := fetchResult["message"].(string); ok { - msg = m - } - return fmt.Errorf("%s", msg) + return generationFailed(fetchResult) case "processing": elapsed := time.Since(startTime).Round(time.Second) eta := "" @@ -114,6 +120,34 @@ func pollAndDownload(cmd *cobra.Command, genType, fetchEndpoint string, result m } } +// generationFailed turns an API "error"/"failed" body into an error that carries +// its message, which may be a string or, for validation errors, an object. +func generationFailed(result map[string]interface{}) error { + switch m := result["message"].(type) { + case string: + if m != "" { + return fmt.Errorf("%s", m) + } + case nil: + default: + if encoded, err := json.Marshal(m); err == nil { + return fmt.Errorf("%s", encoded) + } + } + + return fmt.Errorf("Generation failed") +} + +// responseSummary is the raw body, for errors about a response the CLI could not use. +func responseSummary(result map[string]interface{}) string { + encoded, err := json.Marshal(result) + if err != nil { + return fmt.Sprintf("%v", result) + } + + return string(encoded) +} + func hasOutputURLs(result map[string]interface{}) bool { if output, ok := result["output"].([]interface{}); ok && len(output) > 0 { return true diff --git a/internal/cmd/generate_poll_test.go b/internal/cmd/generate_poll_test.go new file mode 100644 index 0000000..b1f78d8 --- /dev/null +++ b/internal/cmd/generate_poll_test.go @@ -0,0 +1,62 @@ +package cmd + +import ( + "testing" + "time" + + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// A failed generation comes back as HTTP 200 with status "error" and no job id. +// pollAndDownload used to poll fetch/ with that empty id until the timeout. +func generationCommand(t *testing.T, noWait bool) *cobra.Command { + t.Helper() + cmd := &cobra.Command{} + addGenerationFlags(cmd) + require.NoError(t, cmd.Flags().Set("timeout", (2*time.Second).String())) + if noWait { + require.NoError(t, cmd.Flags().Set("no-wait", "true")) + } + + return cmd +} + +func TestPollAndDownload_ReturnsTheApiErrorWithoutPolling(t *testing.T) { + for _, noWait := range []bool{false, true} { + start := time.Now() + + err := pollAndDownload(generationCommand(t, noWait), "video", "/v7/video-fusion/fetch", map[string]interface{}{ + "status": "error", + "code": "provider_error", + "message": "The provider refused the request.", + }) + + require.Error(t, err) + assert.Equal(t, "The provider refused the request.", err.Error()) + assert.Less(t, time.Since(start), time.Second, "it must not poll") + } +} + +func TestPollAndDownload_ReportsAStructuredValidationMessage(t *testing.T) { + err := pollAndDownload(generationCommand(t, false), "image", "/v7/images/fetch", map[string]interface{}{ + "status": "error", + "message": map[string]interface{}{"init_image": []interface{}{"The init image field is required."}}, + }) + + require.Error(t, err) + assert.Contains(t, err.Error(), "The init image field is required.") +} + +func TestPollAndDownload_RefusesToPollWithoutAJobID(t *testing.T) { + start := time.Now() + + err := pollAndDownload(generationCommand(t, false), "video", "/v7/video-fusion/fetch", map[string]interface{}{ + "status": "processing", + }) + + require.Error(t, err) + assert.Contains(t, err.Error(), "no job id") + assert.Less(t, time.Since(start), time.Second, "it must not poll") +}