Skip to content

Commit 57e3b02

Browse files
authored
add timeout exit code for wait polling (PRINFRA-125) (#46)
## Description This follows the merged --wait polling framework with a clearer timeout contract for agents and scripts. Poll timeouts now return exit code 4 only after resource creation is confirmed, emit partial resource data on stdout when available, keep cancel distinct from timeout, and surface command-specific follow-up hints such as manual get commands for the created resource. ## Testing - go test ./internal/client/... -run "ExecuteAndPoll|ExtractJSONPath" -count=1 - go test ./cmd/heygen/... -run "VideoCreate_Wait|VideoAgentCreate_Wait|GeneratedRoot_RootHelp" -count=1 - go test ./...
1 parent 0d532ae commit 57e3b02

11 files changed

Lines changed: 269 additions & 32 deletions

File tree

AGENTS.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ internal/auth/ CredentialResolver interface + implementations
2424
internal/client/ HTTP client, Executor
2525
internal/command/ Spec, Invocation, Groups, Column types
2626
internal/config/ config.Provider interface + implementations
27-
internal/errors/ CLIError, APIError, exit codes (0/1/2/3)
27+
internal/errors/ CLIError, APIError, exit codes (0/1/2/3/4)
2828
internal/output/ Formatter interface (JSONFormatter, HumanFormatter)
2929
internal/paths/ Config/credential file path resolution
3030
gen/ GENERATED — do not hand-edit
@@ -79,4 +79,4 @@ Data(v json.RawMessage, dataField string, columns []command.Column) error
7979
- All automated tests use `httptest.Server`. No real API calls in tests or CI.
8080
- Command tests: use `runCommand()` in `cmd/heygen/testutil_test.go`. It creates a fresh Cobra tree, captures stdout/stderr/exit code, and renders errors through the formatter (same path as `main()`).
8181
- Use `t.Setenv()` for env vars (auto-restored).
82-
- Assert on exit codes (0/1/2/3) **and** stderr envelope shape, not just error presence.
82+
- Assert on exit codes (0/1/2/3/4) **and** stderr envelope shape, not just error presence.

CLAUDE.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ internal/auth/ CredentialResolver interface + implementations
2222
internal/client/ HTTP client, Executor
2323
internal/command/ Spec, Invocation, Groups, Column types
2424
internal/config/ config.Provider interface + implementations
25-
internal/errors/ CLIError, APIError, exit codes (0/1/2/3)
25+
internal/errors/ CLIError, APIError, exit codes (0/1/2/3/4)
2626
internal/output/ Formatter interface (JSONFormatter, HumanFormatter)
2727
internal/paths/ Config/credential file path resolution
2828
gen/ GENERATED — do not hand-edit
@@ -77,4 +77,4 @@ Data(v json.RawMessage, dataField string, columns []command.Column) error
7777
- All automated tests use `httptest.Server`. No real API calls in tests or CI.
7878
- Command tests: use `runCommand()` in `cmd/heygen/testutil_test.go`. It creates a fresh Cobra tree, captures stdout/stderr/exit code, and renders errors through the formatter (same path as `main()`).
7979
- Use `t.Setenv()` for env vars (auto-restored).
80-
- Assert on exit codes (0/1/2/3) **and** stderr envelope shape, not just error presence.
80+
- Assert on exit codes (0/1/2/3/4) **and** stderr envelope shape, not just error presence.

cmd/heygen/builder.go

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,24 @@ func buildCobraCommand(spec *command.Spec, ctx *cmdContext) *cobra.Command {
7878

7979
result, err := ctx.client.ExecuteAndPoll(cmd.Context(), &pollSpec, inv, opts)
8080
if err != nil {
81+
var timeoutErr *client.ErrPollTimeout
82+
if errors.As(err, &timeoutErr) {
83+
if timeoutErr.Data != nil {
84+
if fmtErr := ctx.formatter.Data(timeoutErr.Data, "data", nil); fmtErr != nil {
85+
return fmtErr
86+
}
87+
}
88+
hint := "Re-run the corresponding get command to check the current status manually"
89+
if pc.HintCommand != "" {
90+
hint = fmt.Sprintf("heygen %s %s", pc.HintCommand, timeoutErr.ResourceID)
91+
}
92+
return &clierrors.CLIError{
93+
Code: "timeout",
94+
Message: fmt.Sprintf("polling timed out after %s", timeout),
95+
Hint: hint,
96+
ExitCode: clierrors.ExitTimeout,
97+
}
98+
}
8199
var failErr *client.ErrPollFailed
82100
if errors.As(err, &failErr) {
83101
// Output the failure response (contains error details),

cmd/heygen/builder_test.go

Lines changed: 113 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,16 @@ var videoCreateWaitSpec = &command.Spec{
4343
Examples: []string{"heygen video create --wait"},
4444
}
4545

46+
var videoAgentWaitSpec = &command.Spec{
47+
Group: "video-agent",
48+
Name: "create",
49+
Summary: "Create video with Video Agent",
50+
Endpoint: "/v3/video-agents",
51+
Method: "POST",
52+
BodyEncoding: "json",
53+
Examples: []string{"heygen video-agent create --wait"},
54+
}
55+
4656
func TestGenBuilder_VideoList_Success(t *testing.T) {
4757
srv := setupTestServer(t, map[string]testHandler{
4858
"GET /v3/videos": {
@@ -203,12 +213,112 @@ func TestGenBuilder_VideoCreate_Wait_Timeout(t *testing.T) {
203213

204214
res := runGenCommand(t, srv.URL, "test-key", videoCreateWaitSpec, "create", "--wait", "--timeout", "20ms")
205215

206-
if res.ExitCode != 1 {
207-
t.Fatalf("ExitCode = %d, want 1\nstderr: %s", res.ExitCode, res.Stderr)
216+
if res.ExitCode != clierrors.ExitTimeout {
217+
t.Fatalf("ExitCode = %d, want %d\nstderr: %s", res.ExitCode, clierrors.ExitTimeout, res.Stderr)
208218
}
209-
if !strings.Contains(res.Stderr, "polling timed out before the operation completed") {
219+
if !strings.Contains(res.Stderr, "polling timed out after 20ms") {
210220
t.Fatalf("stderr = %s, want timeout message", res.Stderr)
211221
}
222+
if !strings.Contains(res.Stderr, "heygen video get vid_123") {
223+
t.Fatalf("stderr = %s, want follow-up hint", res.Stderr)
224+
}
225+
226+
var parsed map[string]any
227+
if err := json.Unmarshal([]byte(res.Stdout), &parsed); err != nil {
228+
t.Fatalf("stdout should contain partial response: %v\nstdout: %s", err, res.Stdout)
229+
}
230+
data := parsed["data"].(map[string]any)
231+
if data["status"] != "processing" {
232+
t.Fatalf("status = %v, want processing", data["status"])
233+
}
234+
}
235+
236+
func TestGenBuilder_VideoCreate_Wait_TimeoutBeforeFirstPoll(t *testing.T) {
237+
srv := setupTestServer(t, map[string]testHandler{
238+
"POST /v3/videos": {
239+
StatusCode: 200,
240+
Body: `{"data":{"video_id":"vid_123"}}`,
241+
},
242+
"GET /v3/videos/vid_123": {
243+
StatusCode: 200,
244+
ValidateRequest: func(t *testing.T, r *http.Request) {
245+
t.Helper()
246+
<-r.Context().Done()
247+
},
248+
Body: `{"data":{"video_id":"vid_123","status":"processing"}}`,
249+
},
250+
})
251+
defer srv.Close()
252+
253+
res := runGenCommand(t, srv.URL, "test-key", videoCreateWaitSpec, "create", "--wait", "--timeout", "20ms")
254+
255+
if res.ExitCode != clierrors.ExitTimeout {
256+
t.Fatalf("ExitCode = %d, want %d\nstderr: %s", res.ExitCode, clierrors.ExitTimeout, res.Stderr)
257+
}
258+
if strings.TrimSpace(res.Stdout) != "" {
259+
t.Fatalf("stdout = %q, want empty when no status response was received", res.Stdout)
260+
}
261+
}
262+
263+
func TestGenBuilder_VideoCreate_Wait_Timeout_Human(t *testing.T) {
264+
srv := setupTestServer(t, map[string]testHandler{
265+
"POST /v3/videos": {
266+
StatusCode: 200,
267+
Body: `{"data":{"video_id":"vid_123"}}`,
268+
},
269+
"GET /v3/videos/vid_123": {
270+
StatusCode: 200,
271+
Body: `{"data":{"video_id":"vid_123","status":"processing","created_at":1774712936}}`,
272+
},
273+
})
274+
defer srv.Close()
275+
276+
res := runGenCommand(t, srv.URL, "test-key", videoCreateWaitSpec, "create", "--wait", "--timeout", "20ms", "--human")
277+
278+
if res.ExitCode != clierrors.ExitTimeout {
279+
t.Fatalf("ExitCode = %d, want %d\nstderr: %s", res.ExitCode, clierrors.ExitTimeout, res.Stderr)
280+
}
281+
if !strings.Contains(res.Stderr, "Polling: status=processing") {
282+
t.Fatalf("stderr = %s, want progress output", res.Stderr)
283+
}
284+
if !strings.Contains(res.Stderr, "Error: polling timed out after 20ms") {
285+
t.Fatalf("stderr = %s, want human timeout error", res.Stderr)
286+
}
287+
if !strings.Contains(res.Stderr, "Hint: heygen video get vid_123") {
288+
t.Fatalf("stderr = %s, want human hint", res.Stderr)
289+
}
290+
if !strings.Contains(res.Stdout, "Video Id") && !strings.Contains(res.Stdout, "Video ID") && !strings.Contains(res.Stdout, "video_id") {
291+
t.Fatalf("stdout = %s, want rendered partial result", res.Stdout)
292+
}
293+
if !strings.Contains(res.Stdout, "processing") {
294+
t.Fatalf("stdout = %s, want processing status", res.Stdout)
295+
}
296+
}
297+
298+
func TestGenBuilder_VideoAgentCreate_Wait_Timeout_UsesVideoGetHint(t *testing.T) {
299+
srv := setupTestServer(t, map[string]testHandler{
300+
"POST /v3/video-agents": {
301+
StatusCode: 200,
302+
Body: `{"data":{"session_id":"sess_123","video_id":"vid_123"}}`,
303+
},
304+
"GET /v3/videos/vid_123": {
305+
StatusCode: 200,
306+
Body: `{"data":{"video_id":"vid_123","status":"processing"}}`,
307+
},
308+
})
309+
defer srv.Close()
310+
311+
res := runGenCommand(t, srv.URL, "test-key", videoAgentWaitSpec, "create", "--wait", "--timeout", "20ms")
312+
313+
if res.ExitCode != clierrors.ExitTimeout {
314+
t.Fatalf("ExitCode = %d, want %d\nstderr: %s", res.ExitCode, clierrors.ExitTimeout, res.Stderr)
315+
}
316+
if !strings.Contains(res.Stderr, "heygen video get vid_123") {
317+
t.Fatalf("stderr = %s, want video get hint", res.Stderr)
318+
}
319+
if strings.Contains(res.Stderr, "video-agent get") {
320+
t.Fatalf("stderr = %s, should not suggest video-agent get", res.Stderr)
321+
}
212322
}
213323

214324
func TestGenBuilder_VideoCreate_NoWait(t *testing.T) {

cmd/heygen/generated_root_test.go

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -210,6 +210,20 @@ func TestGeneratedRoot_VideoHelp_RemainsFlat(t *testing.T) {
210210
}
211211
}
212212

213+
func TestGeneratedRoot_RootHelp_ListsTimeoutExitCode(t *testing.T) {
214+
srv := setupTestServer(t, map[string]testHandler{})
215+
defer srv.Close()
216+
217+
res := runCommand(t, srv.URL, "", "--help")
218+
219+
if res.ExitCode != 0 {
220+
t.Fatalf("ExitCode = %d, want 0\nstderr: %s", res.ExitCode, res.Stderr)
221+
}
222+
if !strings.Contains(res.Stdout, "4 Timeout (resource created but operation not yet complete)") {
223+
t.Fatalf("root help missing exit code 4\nstdout: %s", res.Stdout)
224+
}
225+
}
226+
213227
func TestGeneratedRoot_UnknownFlagStillUsageError(t *testing.T) {
214228
srv := setupTestServer(t, map[string]testHandler{})
215229
defer srv.Close()

cmd/heygen/poll_configs.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ var pollConfigs = map[string]*command.PollConfig{
1111
TerminalOK: []string{"completed"},
1212
TerminalFail: []string{"failed"},
1313
IDField: "data.video_id",
14+
HintCommand: "video get",
1415
},
1516
// The create response returns video_translation_ids (plural, always a list
1617
// even for single-language requests). We extract the first element with ".0".
@@ -21,6 +22,7 @@ var pollConfigs = map[string]*command.PollConfig{
2122
TerminalOK: []string{"completed"},
2223
TerminalFail: []string{"failed"},
2324
IDField: "data.video_translation_ids.0",
25+
HintCommand: "video-translate get",
2426
},
2527
// video-agent returns session_id + video_id. We poll the video status.
2628
// video_id can be null in future multi-turn flows — extractJSONPath
@@ -31,5 +33,6 @@ var pollConfigs = map[string]*command.PollConfig{
3133
TerminalOK: []string{"completed"},
3234
TerminalFail: []string{"failed"},
3335
IDField: "data.video_id",
36+
HintCommand: "video get",
3437
},
3538
}

cmd/heygen/root.go

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,14 @@ Environment Variables:
2626
HEYGEN_API_KEY API key for authentication (overrides stored credentials)
2727
HEYGEN_OUTPUT Output format: json, human (default: json)
2828
29-
Use "heygen config list" to see all configuration settings and their sources.`,
29+
Use "heygen config list" to see all configuration settings and their sources.
30+
31+
Exit Codes:
32+
0 Success
33+
1 General error (API error, network failure)
34+
2 Usage error (invalid flags, missing arguments)
35+
3 Authentication error (missing or invalid API key)
36+
4 Timeout (resource created but operation not yet complete)`,
3037
Version: version,
3138
SilenceUsage: true, // we handle usage errors ourselves
3239
SilenceErrors: true, // we handle error output ourselves

internal/client/executor.go

Lines changed: 50 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,17 @@ func (e *ErrPollFailed) Error() string {
4747
return fmt.Sprintf("operation reached terminal failure state: %s", e.Status)
4848
}
4949

50+
// ErrPollTimeout is returned when polling times out after the resource has
51+
// been created but before a terminal status is reached.
52+
type ErrPollTimeout struct {
53+
Data json.RawMessage
54+
ResourceID string
55+
}
56+
57+
func (e *ErrPollTimeout) Error() string {
58+
return fmt.Sprintf("polling timed out (resource %s still in progress)", e.ResourceID)
59+
}
60+
5061
// ExecuteAndPoll executes a create request, then polls a status endpoint until
5162
// the resource reaches a terminal state or the context is canceled.
5263
func (c *Client) ExecuteAndPoll(
@@ -65,7 +76,7 @@ func (c *Client) ExecuteAndPoll(
6576

6677
createResp, err := c.executeWithContext(pollCtx, spec, inv)
6778
if err != nil {
68-
return nil, translatePollContextError(pollCtx, err)
79+
return nil, translateCreateContextError(pollCtx, err)
6980
}
7081

7182
resourceID, err := extractJSONPath(createResp, spec.PollConfig.IDField)
@@ -102,16 +113,21 @@ func (c *Client) ExecuteAndPoll(
102113
MaxDelay: opts.MaxDelay,
103114
}
104115
start := time.Now()
116+
var lastStatusResp json.RawMessage
105117

106118
for attempt := 0; ; attempt++ {
107119
if err := pollCtx.Err(); err != nil {
108-
return nil, newPollContextError(err)
120+
return nil, classifyPollContextError(err, resourceID, lastStatusResp)
109121
}
110122

111123
statusResp, err := c.executeWithContext(pollCtx, statusSpec, statusInv)
112124
if err != nil {
113-
return nil, translatePollContextError(pollCtx, err)
125+
if ctxErr := pollCtx.Err(); ctxErr != nil {
126+
return nil, classifyPollContextError(ctxErr, resourceID, lastStatusResp)
127+
}
128+
return nil, err
114129
}
130+
lastStatusResp = statusResp
115131

116132
status, err := extractJSONPath(statusResp, spec.PollConfig.StatusField)
117133
if err != nil {
@@ -133,7 +149,10 @@ func (c *Client) ExecuteAndPoll(
133149

134150
delay := backoffDelay(attempt, pollBackoff)
135151
if err := waitForRetry(pollCtx, delay); err != nil {
136-
return nil, newPollContextError(err)
152+
if ctxErr := pollCtx.Err(); ctxErr != nil {
153+
return nil, classifyPollContextError(ctxErr, resourceID, lastStatusResp)
154+
}
155+
return nil, err
137156
}
138157
}
139158
}
@@ -402,35 +421,32 @@ func extractJSONPath(raw json.RawMessage, path string) (string, error) {
402421
return value, nil
403422
}
404423

405-
// translatePollContextError converts context deadline/cancellation errors into
406-
// user-friendly CLIErrors. This includes unwrapping context errors that were
407-
// stringified into CLIError.Message by executeWithContext — CLIError doesn't
408-
// preserve the error chain, so string matching is the fallback. If the message
409-
// format in executeWithContext changes, this degrades to returning the original
410-
// error (generic network failure), which is safe but less user-friendly.
411-
func translatePollContextError(ctx context.Context, err error) error {
424+
// translateCreateContextError converts timeout/cancel errors before a resource
425+
// ID is known into user-friendly CLIErrors. At this stage the client cannot
426+
// confirm whether creation succeeded, so exit code stays general.
427+
func translateCreateContextError(ctx context.Context, err error) error {
412428
if ctxErr := ctx.Err(); ctxErr != nil {
413-
return newPollContextError(ctxErr)
429+
return newCreateContextError(ctxErr)
414430
}
415431
if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) {
416-
return newPollContextError(err)
432+
return newCreateContextError(err)
417433
}
418434

419435
var cliErr *clierrors.CLIError
420436
if errors.As(err, &cliErr) && cliErr.Code == "network_error" {
421437
msg := strings.ToLower(cliErr.Message)
422438
switch {
423439
case strings.Contains(msg, "context deadline exceeded"):
424-
return newPollContextError(context.DeadlineExceeded)
440+
return newCreateContextError(context.DeadlineExceeded)
425441
case strings.Contains(msg, "context canceled"), strings.Contains(msg, "context cancelled"):
426-
return newPollContextError(context.Canceled)
442+
return newCreateContextError(context.Canceled)
427443
}
428444
}
429445

430446
return err
431447
}
432448

433-
func newPollContextError(err error) error {
449+
func newCreateContextError(err error) error {
434450
switch {
435451
case errors.Is(err, context.DeadlineExceeded):
436452
return &clierrors.CLIError{
@@ -451,6 +467,24 @@ func newPollContextError(err error) error {
451467
}
452468
}
453469

470+
func classifyPollContextError(err error, resourceID string, lastResp json.RawMessage) error {
471+
if err == nil {
472+
return clierrors.New("unexpected nil context error during polling")
473+
}
474+
if errors.Is(err, context.DeadlineExceeded) {
475+
return &ErrPollTimeout{
476+
Data: lastResp,
477+
ResourceID: resourceID,
478+
}
479+
}
480+
return &clierrors.CLIError{
481+
Code: "canceled",
482+
Message: "polling was canceled before the operation completed",
483+
Hint: "Re-run the corresponding get command to check the current status manually",
484+
ExitCode: clierrors.ExitGeneral,
485+
}
486+
}
487+
454488
// parseErrorResponse parses an API error response into a CLIError.
455489
func parseErrorResponse(statusCode int, body []byte, requestID string) *clierrors.CLIError {
456490
var envelope struct {

0 commit comments

Comments
 (0)