Skip to content

Commit 4b817b5

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 50683ba commit 4b817b5

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
@@ -877,6 +877,97 @@ func TestExtractJSONPath_ArrayOnNonArray(t *testing.T) {
877877
}
878878
}
879879

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

0 commit comments

Comments
 (0)