Skip to content

Commit 6559237

Browse files
somanshreddyclaude
andcommitted
client: reject batch --wait with clear error
When IDField uses an array index (e.g., data.video_translation_ids.0) and the array has >1 element, return a batch_not_supported error instead of silently polling only the first resource. Single-element arrays (single-language translation) work normally. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent ead17b9 commit 6559237

4 files changed

Lines changed: 142 additions & 3 deletions

File tree

cmd/heygen/builder.go

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package main
22

33
import (
44
"encoding/json"
5+
"errors"
56
"fmt"
67
"io"
78
"os"
@@ -103,7 +104,7 @@ func buildCobraCommand(spec *command.Spec, ctx *cmdContext) *cobra.Command {
103104
if errors.As(err, &failErr) {
104105
// Output the failure response (contains error details),
105106
// then signal failure via CLIError for exit code 1.
106-
if fmtErr := ctx.formatter.Data(failErr.Data, spec.DataField, nil); fmtErr != nil {
107+
if fmtErr := ctx.formatter.Data(failErr.Data, "data", nil); fmtErr != nil {
107108
return fmtErr
108109
}
109110
return clierrors.New(failErr.Error())
@@ -114,7 +115,7 @@ func buildCobraCommand(spec *command.Spec, ctx *cmdContext) *cobra.Command {
114115
// --wait result is a single resource from the GET endpoint.
115116
// Columns are nil — HumanFormatter renders single objects as
116117
// key-value pairs, not tables. This matches `heygen video get`.
117-
return ctx.formatter.Data(result, spec.DataField, nil)
118+
return ctx.formatter.Data(result, "data", nil)
118119
}
119120
}
120121

cmd/heygen/builder_test.go

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,6 @@ var videoCreateWaitSpec = &command.Spec{
4040
Endpoint: "/v3/videos",
4141
Method: "POST",
4242
BodyEncoding: "json",
43-
DataField: "data",
4443
Examples: []string{"heygen video create --wait"},
4544
}
4645

internal/client/executor.go

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,17 @@ func (c *Client) ExecuteAndPoll(
7979
))
8080
}
8181

82+
// If IDField uses an array index (e.g., "data.ids.0"), reject batch
83+
// responses with multiple IDs. --wait only supports single-resource polling.
84+
if arrayLen, ok := extractArrayLen(createResp, spec.PollConfig.IDField); ok && arrayLen > 1 {
85+
return nil, &clierrors.CLIError{
86+
Code: "batch_not_supported",
87+
Message: fmt.Sprintf("--wait does not support batch operations (got %d resources)", arrayLen),
88+
Hint: "Poll each resource individually with the corresponding get command",
89+
ExitCode: clierrors.ExitGeneral,
90+
}
91+
}
92+
8293
pathParams, err := statusPathParams(spec.PollConfig.StatusEndpoint, resourceID)
8394
if err != nil {
8495
return nil, err
@@ -409,6 +420,43 @@ func statusPathParams(endpoint, resourceID string) (map[string]string, error) {
409420
return params, nil
410421
}
411422

423+
// extractArrayLen checks if a dot-notation path ends with a numeric index
424+
// (e.g., "data.ids.0"), and if so, returns the length of that array.
425+
// Returns (0, false) if the path doesn't end with an array index.
426+
func extractArrayLen(raw json.RawMessage, path string) (int, bool) {
427+
parts := strings.Split(path, ".")
428+
if len(parts) < 2 {
429+
return 0, false
430+
}
431+
// Check if the last segment is a numeric index
432+
if _, err := strconv.Atoi(parts[len(parts)-1]); err != nil {
433+
return 0, false
434+
}
435+
// Walk to the parent array
436+
arrayPath := strings.Join(parts[:len(parts)-1], ".")
437+
438+
var current any
439+
if err := json.Unmarshal(raw, &current); err != nil {
440+
return 0, false
441+
}
442+
for _, part := range strings.Split(arrayPath, ".") {
443+
obj, ok := current.(map[string]any)
444+
if !ok {
445+
return 0, false
446+
}
447+
next, ok := obj[part]
448+
if !ok {
449+
return 0, false
450+
}
451+
current = next
452+
}
453+
arr, ok := current.([]any)
454+
if !ok {
455+
return 0, false
456+
}
457+
return len(arr), true
458+
}
459+
412460
// extractJSONPath extracts a string value from JSON using dot-notation.
413461
// Supports object fields ("data.status") and array indices ("data.ids.0").
414462
func extractJSONPath(raw json.RawMessage, path string) (string, error) {

internal/client/executor_test.go

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -896,6 +896,97 @@ func TestExtractJSONPath_ArrayOnNonArray(t *testing.T) {
896896
}
897897
}
898898

899+
func TestExecuteAndPoll_RejectsBatch(t *testing.T) {
900+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
901+
w.WriteHeader(http.StatusOK)
902+
// Create response returns multiple IDs (batch translation)
903+
_, _ = w.Write([]byte(`{"data":{"video_translation_ids":["id_1","id_2","id_3"]}}`))
904+
}))
905+
defer srv.Close()
906+
907+
c := New("key", WithBaseURL(srv.URL), WithHTTPClient(srv.Client()), WithMaxRetries(0))
908+
909+
spec := &command.Spec{
910+
Endpoint: "/v3/video-translations",
911+
Method: http.MethodPost,
912+
BodyEncoding: "json",
913+
PollConfig: &command.PollConfig{
914+
StatusEndpoint: "/v3/video-translations/{video_translation_id}",
915+
StatusField: "data.status",
916+
TerminalOK: []string{"completed"},
917+
TerminalFail: []string{"failed"},
918+
IDField: "data.video_translation_ids.0",
919+
},
920+
}
921+
922+
_, err := c.ExecuteAndPoll(context.Background(), spec, emptyInvocation(), PollOptions{
923+
BaseDelay: time.Millisecond,
924+
MaxDelay: time.Millisecond,
925+
})
926+
if err == nil {
927+
t.Fatal("expected error, got nil")
928+
}
929+
930+
var cliErr *clierrors.CLIError
931+
if !errors.As(err, &cliErr) {
932+
t.Fatalf("expected *CLIError, got %T: %v", err, err)
933+
}
934+
if cliErr.Code != "batch_not_supported" {
935+
t.Fatalf("code = %q, want %q", cliErr.Code, "batch_not_supported")
936+
}
937+
if !strings.Contains(cliErr.Message, "3 resources") {
938+
t.Fatalf("message = %q, want mention of 3 resources", cliErr.Message)
939+
}
940+
}
941+
942+
func TestExecuteAndPoll_SingleArrayElement_OK(t *testing.T) {
943+
var statusCalls int
944+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
945+
switch r.URL.Path {
946+
case "/v3/video-translations":
947+
w.WriteHeader(http.StatusOK)
948+
// Single-language: array with 1 element
949+
_, _ = w.Write([]byte(`{"data":{"video_translation_ids":["trans_123"]}}`))
950+
case "/v3/video-translations/trans_123":
951+
statusCalls++
952+
w.WriteHeader(http.StatusOK)
953+
_, _ = w.Write([]byte(`{"data":{"id":"trans_123","status":"completed"}}`))
954+
default:
955+
t.Fatalf("unexpected path %s", r.URL.Path)
956+
}
957+
}))
958+
defer srv.Close()
959+
960+
c := New("key", WithBaseURL(srv.URL), WithHTTPClient(srv.Client()), WithMaxRetries(0))
961+
962+
spec := &command.Spec{
963+
Endpoint: "/v3/video-translations",
964+
Method: http.MethodPost,
965+
BodyEncoding: "json",
966+
PollConfig: &command.PollConfig{
967+
StatusEndpoint: "/v3/video-translations/{video_translation_id}",
968+
StatusField: "data.status",
969+
TerminalOK: []string{"completed"},
970+
TerminalFail: []string{"failed"},
971+
IDField: "data.video_translation_ids.0",
972+
},
973+
}
974+
975+
result, err := c.ExecuteAndPoll(context.Background(), spec, emptyInvocation(), PollOptions{
976+
BaseDelay: time.Millisecond,
977+
MaxDelay: time.Millisecond,
978+
})
979+
if err != nil {
980+
t.Fatalf("unexpected error: %v", err)
981+
}
982+
if statusCalls != 1 {
983+
t.Fatalf("statusCalls = %d, want 1", statusCalls)
984+
}
985+
if !strings.Contains(string(result), "completed") {
986+
t.Fatalf("result = %s, want completed", result)
987+
}
988+
}
989+
899990
func pollableVideoCreateSpec() *command.Spec {
900991
return &command.Spec{
901992
Endpoint: "/v3/videos",

0 commit comments

Comments
 (0)