Skip to content

Commit 0d532ae

Browse files
authored
cmd: add --wait polling framework (PRINFRA-125) (#34)
## Description Adds `--wait` flag to async commands (video create, video-translate create, video-agent create). When set, the CLI polls the status endpoint with exponential backoff until the resource reaches a terminal state, then outputs the final resource JSON — same as running the corresponding `get` command after completion. **How it works:** 1. Execute the create request, extract the resource ID from the response (via dot-notation `IDField`, e.g., `data.video_id`) 2. Poll the status endpoint (e.g., `GET /v3/videos/{video_id}`) with exponential backoff (2s initial, up to 30s, with jitter) 3. Check the status field (e.g., `data.status`) against terminal states 4. Success (`completed`) → stdout gets the final resource JSON, exit 0 5. Failure (`failed`) → stdout gets the failure response (contains error details), stderr gets structured error, exit 1 6. Timeout (`--timeout`, default 10m) → stderr gets timeout message with hint, exit 1 **PollConfig registrations** (hand-written in `cmd/heygen/poll_configs.go`): | Command | Status Endpoint | ID Field | Terminal States | |---|---|---|---| | `video create` | `GET /v3/videos/{video_id}` | `data.video_id` | OK: `completed`, Fail: `failed` | | `video-translate create` | `GET /v3/video-translations/{video_translation_id}` | `data.video_translation_ids.0` | OK: `completed`, Fail: `failed` | | `video-agent create` | `GET /v3/videos/{video_id}` | `data.video_id` | OK: `completed`, Fail: `failed` | PollConfigs are looked up at call time in the builder via `pollConfigs[spec.Group+"/"+spec.Name]` — Specs are never mutated. Adding polling for a new async command is one entry in `poll_configs.go`. Field names and terminal states verified against experiment-framework DTOs. **Timeout:** Owned by `ensurePollContext` inside `ExecuteAndPoll`. The builder passes `cmd.Context()` and `Timeout` in `PollOptions`; `ensurePollContext` adds the deadline. Single timeout owner. **Context-aware:** Ctrl-C cancellation and timeout both work. Context errors from `Execute()` (wrapped as `network_error` in CLIError) are unwrapped and translated to clean `timeout`/`canceled` CLIErrors. **Progress:** Only emitted in `--human` mode — one line per status *change* (`Polling: status=processing (elapsed 12s)`). JSON mode keeps stderr clean for machine consumption (structured errors only). **Failure response preserved:** `ErrPollFailed` carries the full status response. The builder outputs it to stdout (users see error details without a second API call) then returns exit 1. **`--human` support:** The `--wait` path passes `client.APIDataField` and nil columns to the formatter — single objects render as key-value pairs, matching the corresponding `get` command. **Batch rejection:** `video-translate create` returns a list of IDs (`video_translation_ids`). `--wait` extracts the first element via array index (`.0`). If the list has >1 element (multi-language batch), returns `batch_not_supported` error with hint to poll manually. Single-language requests work normally. **`extractJSONPath` supports array indices:** Dot-notation paths like `data.ids.0` walk into arrays. Used for video-translate's list-based ID field. Stacked on PR #31 (pagination) → PR #29 (retries). Linear: PRINFRA-125 ## Testing 10 executor tests: immediate success, polls-until-complete (3 status calls), failure state with response preserved, timeout during backoff, timeout during HTTP request (server blocks), create fails (no polling), status callback, extractJSONPath (nested, missing, array index, array out of bounds, non-array), batch rejection (3 IDs), single-array-element OK. 5 builder integration tests: wait success (no progress in JSON mode), wait failure (response on stdout), wait timeout, no-wait (create response only), wait on non-pollable command (unknown flag). 1 validation test: all PollConfig keys match generated Specs (catches orphaned configs). All tests use `httptest.Server` — no real API calls. Polling delays set to 1ms in tests for fast execution.
1 parent 89a8115 commit 0d532ae

6 files changed

Lines changed: 1005 additions & 0 deletions

File tree

cmd/heygen/builder.go

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,18 @@ package main
22

33
import (
44
"encoding/json"
5+
"errors"
56
"fmt"
67
"io"
78
"os"
89
"strconv"
910
"strings"
11+
"time"
1012

1113
"github.com/heygen-com/heygen-cli/internal/client"
1214
"github.com/heygen-com/heygen-cli/internal/command"
1315
clierrors "github.com/heygen-com/heygen-cli/internal/errors"
16+
"github.com/heygen-com/heygen-cli/internal/output"
1417
"github.com/spf13/cobra"
1518
)
1619

@@ -42,6 +45,57 @@ func buildCobraCommand(spec *command.Spec, ctx *cmdContext) *cobra.Command {
4245
return err
4346
}
4447

48+
// Poll config is looked up at call time, not attached to Spec.
49+
// Spec is immutable (generated); poll configs are hand-written in cmd/.
50+
pc := pollConfigs[spec.Group+"/"+spec.Name]
51+
if pc != nil {
52+
wait, _ := cmd.Flags().GetBool("wait")
53+
if wait {
54+
timeout, _ := cmd.Flags().GetDuration("timeout")
55+
56+
// Let ExecuteAndPoll own the timeout via ensurePollContext.
57+
// Don't wrap cmd.Context() with WithTimeout here.
58+
pollSpec := *spec
59+
pollSpec.PollConfig = pc
60+
61+
opts := client.PollOptions{
62+
Timeout: timeout,
63+
BaseDelay: 2 * time.Second,
64+
MaxDelay: 30 * time.Second,
65+
}
66+
// Only emit progress in human mode. JSON mode keeps stderr
67+
// clean for machine consumption (structured errors only).
68+
if _, ok := ctx.formatter.(*output.HumanFormatter); ok {
69+
var lastStatus string
70+
opts.OnStatus = func(status string, elapsed time.Duration) {
71+
if status == lastStatus {
72+
return
73+
}
74+
fmt.Fprintf(cmd.ErrOrStderr(), "Polling: status=%s (elapsed %s)\n", status, elapsed.Round(time.Second))
75+
lastStatus = status
76+
}
77+
}
78+
79+
result, err := ctx.client.ExecuteAndPoll(cmd.Context(), &pollSpec, inv, opts)
80+
if err != nil {
81+
var failErr *client.ErrPollFailed
82+
if errors.As(err, &failErr) {
83+
// Output the failure response (contains error details),
84+
// then signal failure via CLIError for exit code 1.
85+
if fmtErr := ctx.formatter.Data(failErr.Data, "data", nil); fmtErr != nil {
86+
return fmtErr
87+
}
88+
return clierrors.New(failErr.Error())
89+
}
90+
return err
91+
}
92+
93+
// --wait result is a single resource from the GET endpoint.
94+
// Columns are nil — HumanFormatter renders single objects as
95+
// key-value pairs, not tables. This matches `heygen video get`.
96+
return ctx.formatter.Data(result, "data", nil)
97+
}
98+
}
4599
result, err := ctx.client.Execute(spec, inv)
46100
if err != nil {
47101
return err
@@ -56,6 +110,10 @@ func buildCobraCommand(spec *command.Spec, ctx *cmdContext) *cobra.Command {
56110
registerFlag(cmd, flag)
57111
}
58112

113+
if pollConfigs[spec.Group+"/"+spec.Name] != nil {
114+
cmd.Flags().Bool("wait", false, "Poll until the operation completes or fails")
115+
cmd.Flags().Duration("timeout", 10*time.Minute, "Max time to wait when using --wait")
116+
}
59117
// Add -d/--data for commands with JSON request bodies
60118
if spec.BodyEncoding == "json" {
61119
cmd.Flags().StringVarP(&rawData, "data", "d", "",

cmd/heygen/builder_test.go

Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,18 @@ var videoListSpec = &command.Spec{
3131
Examples: []string{"heygen video list --limit 10"},
3232
}
3333

34+
// videoCreateWaitSpec has Group/Name matching pollConfigs["video/create"],
35+
// so the builder picks up PollConfig at call time — no need to set it here.
36+
var videoCreateWaitSpec = &command.Spec{
37+
Group: "video",
38+
Name: "create",
39+
Summary: "Create a video",
40+
Endpoint: "/v3/videos",
41+
Method: "POST",
42+
BodyEncoding: "json",
43+
Examples: []string{"heygen video create --wait"},
44+
}
45+
3446
func TestGenBuilder_VideoList_Success(t *testing.T) {
3547
srv := setupTestServer(t, map[string]testHandler{
3648
"GET /v3/videos": {
@@ -90,6 +102,177 @@ func TestGenBuilder_VideoList_Flags(t *testing.T) {
90102
}
91103
}
92104

105+
func TestGenBuilder_VideoCreate_Wait_Success(t *testing.T) {
106+
var statusCalls int
107+
srv := setupTestServer(t, map[string]testHandler{
108+
"POST /v3/videos": {
109+
StatusCode: 200,
110+
Body: `{"data":{"video_id":"vid_123"}}`,
111+
},
112+
"GET /v3/videos/vid_123": {
113+
StatusCode: 200,
114+
ValidateRequest: func(t *testing.T, r *http.Request) {
115+
t.Helper()
116+
statusCalls++
117+
},
118+
Body: `{"data":{"video_id":"vid_123","status":"processing"}}`,
119+
},
120+
})
121+
defer srv.Close()
122+
123+
originalHandler := srv.Config.Handler
124+
srv.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
125+
if r.Method == http.MethodGet && r.URL.Path == "/v3/videos/vid_123" {
126+
statusCalls++
127+
w.WriteHeader(http.StatusOK)
128+
if statusCalls < 2 {
129+
_, _ = w.Write([]byte(`{"data":{"video_id":"vid_123","status":"processing"}}`))
130+
return
131+
}
132+
_, _ = w.Write([]byte(`{"data":{"video_id":"vid_123","status":"completed","video_url":"https://cdn.test/video.mp4"}}`))
133+
return
134+
}
135+
originalHandler.ServeHTTP(w, r)
136+
})
137+
138+
res := runGenCommand(t, srv.URL, "test-key", videoCreateWaitSpec, "create", "--wait")
139+
140+
if res.ExitCode != 0 {
141+
t.Fatalf("ExitCode = %d, want 0\nstderr: %s", res.ExitCode, res.Stderr)
142+
}
143+
144+
var parsed map[string]any
145+
if err := json.Unmarshal([]byte(res.Stdout), &parsed); err != nil {
146+
t.Fatalf("stdout is not valid JSON: %v\nstdout: %s", err, res.Stdout)
147+
}
148+
data := parsed["data"].(map[string]any)
149+
if data["status"] != "completed" {
150+
t.Fatalf("status = %v, want completed", data["status"])
151+
}
152+
// JSON mode: no progress on stderr (keeps it machine-readable).
153+
// Progress is only emitted in --human mode.
154+
if strings.Contains(res.Stderr, "Polling:") {
155+
t.Fatalf("stderr should not contain progress in JSON mode: %s", res.Stderr)
156+
}
157+
}
158+
159+
func TestGenBuilder_VideoCreate_Wait_Failure(t *testing.T) {
160+
srv := setupTestServer(t, map[string]testHandler{
161+
"POST /v3/videos": {
162+
StatusCode: 200,
163+
Body: `{"data":{"video_id":"vid_123"}}`,
164+
},
165+
"GET /v3/videos/vid_123": {
166+
StatusCode: 200,
167+
Body: `{"data":{"video_id":"vid_123","status":"failed"}}`,
168+
},
169+
})
170+
defer srv.Close()
171+
172+
res := runGenCommand(t, srv.URL, "test-key", videoCreateWaitSpec, "create", "--wait")
173+
174+
if res.ExitCode != 1 {
175+
t.Fatalf("ExitCode = %d, want 1\nstderr: %s", res.ExitCode, res.Stderr)
176+
}
177+
if !strings.Contains(res.Stderr, "operation reached terminal failure state: failed") {
178+
t.Fatalf("stderr = %s, want failure message", res.Stderr)
179+
}
180+
// Failure response should be output to stdout so users can see error details
181+
var parsed map[string]any
182+
if err := json.Unmarshal([]byte(res.Stdout), &parsed); err != nil {
183+
t.Fatalf("stdout should contain failure response: %v\nstdout: %s", err, res.Stdout)
184+
}
185+
data := parsed["data"].(map[string]any)
186+
if data["status"] != "failed" {
187+
t.Fatalf("status = %v, want failed", data["status"])
188+
}
189+
}
190+
191+
func TestGenBuilder_VideoCreate_Wait_Timeout(t *testing.T) {
192+
srv := setupTestServer(t, map[string]testHandler{
193+
"POST /v3/videos": {
194+
StatusCode: 200,
195+
Body: `{"data":{"video_id":"vid_123"}}`,
196+
},
197+
"GET /v3/videos/vid_123": {
198+
StatusCode: 200,
199+
Body: `{"data":{"video_id":"vid_123","status":"processing"}}`,
200+
},
201+
})
202+
defer srv.Close()
203+
204+
res := runGenCommand(t, srv.URL, "test-key", videoCreateWaitSpec, "create", "--wait", "--timeout", "20ms")
205+
206+
if res.ExitCode != 1 {
207+
t.Fatalf("ExitCode = %d, want 1\nstderr: %s", res.ExitCode, res.Stderr)
208+
}
209+
if !strings.Contains(res.Stderr, "polling timed out before the operation completed") {
210+
t.Fatalf("stderr = %s, want timeout message", res.Stderr)
211+
}
212+
}
213+
214+
func TestGenBuilder_VideoCreate_NoWait(t *testing.T) {
215+
var statusCalled bool
216+
srv := setupTestServer(t, map[string]testHandler{
217+
"POST /v3/videos": {
218+
StatusCode: 200,
219+
Body: `{"data":{"video_id":"vid_123","status":"pending"}}`,
220+
},
221+
"GET /v3/videos/vid_123": {
222+
StatusCode: 200,
223+
ValidateRequest: func(t *testing.T, r *http.Request) {
224+
t.Helper()
225+
statusCalled = true
226+
},
227+
Body: `{"data":{"video_id":"vid_123","status":"completed"}}`,
228+
},
229+
})
230+
defer srv.Close()
231+
232+
res := runGenCommand(t, srv.URL, "test-key", videoCreateWaitSpec, "create")
233+
234+
if res.ExitCode != 0 {
235+
t.Fatalf("ExitCode = %d, want 0\nstderr: %s", res.ExitCode, res.Stderr)
236+
}
237+
if statusCalled {
238+
t.Fatal("status endpoint should not be called without --wait")
239+
}
240+
var parsed map[string]any
241+
if err := json.Unmarshal([]byte(res.Stdout), &parsed); err != nil {
242+
t.Fatalf("stdout is not valid JSON: %v\nstdout: %s", err, res.Stdout)
243+
}
244+
data := parsed["data"].(map[string]any)
245+
if data["status"] != "pending" {
246+
t.Fatalf("status = %v, want pending", data["status"])
247+
}
248+
}
249+
250+
func TestGenBuilder_VideoCreate_WaitNotAvailable(t *testing.T) {
251+
nonPollable := &command.Spec{
252+
Group: "video",
253+
Name: "get",
254+
Summary: "Get video",
255+
Endpoint: "/v3/videos/{video_id}",
256+
Method: "GET",
257+
Args: []command.ArgSpec{
258+
{Name: "video-id", Param: "video_id"},
259+
},
260+
Examples: []string{"heygen video get <video-id>"},
261+
}
262+
263+
srv := setupTestServer(t, map[string]testHandler{})
264+
defer srv.Close()
265+
266+
res := runGenCommand(t, srv.URL, "test-key", nonPollable, "get", "vid_123", "--wait")
267+
268+
if res.ExitCode != 2 {
269+
t.Fatalf("ExitCode = %d, want 2\nstderr: %s", res.ExitCode, res.Stderr)
270+
}
271+
if !strings.Contains(res.Stderr, "unknown flag: --wait") {
272+
t.Fatalf("stderr = %s, want unknown flag error", res.Stderr)
273+
}
274+
}
275+
93276
func TestGenBuilder_PostWithBodyFlags(t *testing.T) {
94277
var gotBody map[string]any
95278

@@ -407,6 +590,9 @@ func runGeneratedRootCommand(t *testing.T, serverURL, apiKey string, groups map[
407590

408591
t.Setenv("HEYGEN_API_KEY", apiKey)
409592
t.Setenv("HEYGEN_API_BASE", serverURL)
593+
if _, ok := os.LookupEnv("HEYGEN_CONFIG_DIR"); !ok {
594+
t.Setenv("HEYGEN_CONFIG_DIR", t.TempDir())
595+
}
410596

411597
root := newRootCmdWithSpecs("test", formatter, groups)
412598
root.SetOut(&stdout)

cmd/heygen/poll_configs.go

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
package main
2+
3+
import "github.com/heygen-com/heygen-cli/internal/command"
4+
5+
// pollConfigs maps "group/spec.Name" to PollConfig for async commands.
6+
// Keys use the full spec name to avoid collisions within a group.
7+
var pollConfigs = map[string]*command.PollConfig{
8+
"video/create": {
9+
StatusEndpoint: "/v3/videos/{video_id}",
10+
StatusField: "data.status",
11+
TerminalOK: []string{"completed"},
12+
TerminalFail: []string{"failed"},
13+
IDField: "data.video_id",
14+
},
15+
// The create response returns video_translation_ids (plural, always a list
16+
// even for single-language requests). We extract the first element with ".0".
17+
// Batch mode (multiple languages) is not supported by --wait.
18+
"video-translate/create": {
19+
StatusEndpoint: "/v3/video-translations/{video_translation_id}",
20+
StatusField: "data.status",
21+
TerminalOK: []string{"completed"},
22+
TerminalFail: []string{"failed"},
23+
IDField: "data.video_translation_ids.0",
24+
},
25+
// video-agent returns session_id + video_id. We poll the video status.
26+
// video_id can be null in future multi-turn flows — extractJSONPath
27+
// will return an error, which is correct (can't poll without an ID).
28+
"video-agent/create": {
29+
StatusEndpoint: "/v3/videos/{video_id}",
30+
StatusField: "data.status",
31+
TerminalOK: []string{"completed"},
32+
TerminalFail: []string{"failed"},
33+
IDField: "data.video_id",
34+
},
35+
}

cmd/heygen/poll_configs_test.go

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
package main
2+
3+
import (
4+
"testing"
5+
6+
"github.com/heygen-com/heygen-cli/gen"
7+
)
8+
9+
func TestPollConfigs_AllReferencedCommandsExist(t *testing.T) {
10+
for key := range pollConfigs {
11+
found := false
12+
for group, specs := range gen.Groups {
13+
for _, spec := range specs {
14+
if key == group+"/"+spec.Name {
15+
found = true
16+
break
17+
}
18+
}
19+
if found {
20+
break
21+
}
22+
}
23+
24+
if !found {
25+
t.Errorf("poll config key %q does not match any generated spec", key)
26+
}
27+
}
28+
}

0 commit comments

Comments
 (0)