Skip to content

Commit 3c3340c

Browse files
somanshreddyclaude
andauthored
cli: classify video-download failures into specific codes (#207)
* cli: classify video-download failures into specific codes Replace the generic `error` on every download-path failure with specific codes: response_parse_error (unparseable video-get response / unusable URL), network_error (transport), download_url_expired (403/404 on the presigned asset URL), asset_download_failed (asset-host 5xx/other), download_interrupted (mid-stream), file_io_error (local temp/finalize/rename), internal_error (encoding our own output). Each carries an actionable hint. No exit-code changes. asset_download_failed is intentionally CLI-specific: the API's own `download_failed` is a 400 for a bad user-provided input URL (opposite meaning), so reusing it would collide. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * cli: namespace CLI-originated download/encode error codes with cli_ prefix Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent d690400 commit 3c3340c

2 files changed

Lines changed: 256 additions & 9 deletions

File tree

cmd/heygen/video_download.go

Lines changed: 68 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,12 @@ func newVideoDownloadCmd(ctx *cmdContext) *cobra.Command {
104104
"path": dest,
105105
})
106106
if err != nil {
107-
return clierrors.New(fmt.Sprintf("failed to encode response: %v", err))
107+
return &clierrors.CLIError{
108+
Code: "cli_response_encode_error",
109+
Message: fmt.Sprintf("failed to encode response: %v", err),
110+
Hint: "Please report this CLI bug with the command you ran.",
111+
ExitCode: clierrors.ExitGeneral,
112+
}
108113
}
109114

110115
return ctx.formatter.Data(data, "", nil)
@@ -122,7 +127,12 @@ func extractAssetURL(raw json.RawMessage, videoID string, info assetInfo) (strin
122127
Data map[string]json.RawMessage `json:"data"`
123128
}
124129
if err := json.Unmarshal(raw, &resp); err != nil {
125-
return "", clierrors.New("failed to parse video response")
130+
return "", &clierrors.CLIError{
131+
Code: "cli_response_parse_error",
132+
Message: "failed to parse the video response",
133+
Hint: "The API response could not be parsed. Retry; if it persists, report it (possible CLI/API mismatch).",
134+
ExitCode: clierrors.ExitGeneral,
135+
}
126136
}
127137

128138
var status string
@@ -180,45 +190,94 @@ func assetHint(field string) string {
180190
func downloadFile(ctx context.Context, videoURL, dest string) error {
181191
req, err := http.NewRequestWithContext(ctx, http.MethodGet, videoURL, nil)
182192
if err != nil {
183-
return clierrors.New(fmt.Sprintf("failed to build download request: %v", err))
193+
return &clierrors.CLIError{
194+
Code: "cli_response_parse_error",
195+
Message: fmt.Sprintf("the download URL returned by the API is unusable: %v", err),
196+
Hint: "Re-fetch a fresh URL: heygen video get <video_id>",
197+
ExitCode: clierrors.ExitGeneral,
198+
}
184199
}
185200

186201
resp, err := downloadClient.Do(req)
187202
if err != nil {
188-
return clierrors.New(fmt.Sprintf("failed to download video: %v", err))
203+
// network_error is intentionally NOT cli_-prefixed. It is a shared code
204+
// also emitted by the API-executor transport path
205+
// (internal/client/executor.go); kept bare so both sites agree. Do not
206+
// prefix one site without the other.
207+
return &clierrors.CLIError{
208+
Code: "network_error",
209+
Message: fmt.Sprintf("failed to download video: %v", err),
210+
Hint: "This is usually a temporary network issue. Check your connection and retry.",
211+
ExitCode: clierrors.ExitGeneral,
212+
}
189213
}
190214
defer resp.Body.Close()
191215

192216
if resp.StatusCode != http.StatusOK {
193-
return clierrors.New(fmt.Sprintf("download failed with HTTP %d", resp.StatusCode))
217+
// A presigned asset URL that 403s/404s is expired or revoked (client can
218+
// re-fetch); any other status is the asset host (our storage/CDN) failing.
219+
if resp.StatusCode == http.StatusForbidden || resp.StatusCode == http.StatusNotFound {
220+
return &clierrors.CLIError{
221+
Code: "cli_download_url_expired",
222+
Message: fmt.Sprintf("download link expired or unavailable (HTTP %d)", resp.StatusCode),
223+
Hint: "This download link has expired. Re-fetch a fresh URL: heygen video get <video_id>",
224+
ExitCode: clierrors.ExitGeneral,
225+
}
226+
}
227+
return &clierrors.CLIError{
228+
Code: "cli_download_failed",
229+
Message: fmt.Sprintf("download failed with HTTP %d", resp.StatusCode),
230+
Hint: "The asset host returned an error fetching the file. This is usually transient — retry shortly.",
231+
ExitCode: clierrors.ExitGeneral,
232+
}
194233
}
195234

196235
// Write to a temp file in the same directory, then rename on success.
197236
// This prevents destroying an existing file on partial download failure.
198237
dir := filepath.Dir(dest)
199238
tmp, err := os.CreateTemp(dir, ".heygen-download-*.tmp")
200239
if err != nil {
201-
return clierrors.New(fmt.Sprintf("failed to create temp file in %q: %v", dir, err))
240+
return &clierrors.CLIError{
241+
Code: "cli_file_io_error",
242+
Message: fmt.Sprintf("failed to create temp file in %q: %v", dir, err),
243+
Hint: "Could not write locally. Check the destination path and free disk space.",
244+
ExitCode: clierrors.ExitGeneral,
245+
}
202246
}
203247
tmpPath := tmp.Name()
204248

205249
_, copyErr := io.Copy(tmp, resp.Body)
206250
closeErr := tmp.Close()
207251
if copyErr != nil {
208252
_ = os.Remove(tmpPath)
209-
return clierrors.New(fmt.Sprintf("download interrupted: %v", copyErr))
253+
return &clierrors.CLIError{
254+
Code: "cli_download_interrupted",
255+
Message: fmt.Sprintf("download interrupted: %v", copyErr),
256+
Hint: "The transfer was cut off. Retry the download. The partial file was cleaned up.",
257+
ExitCode: clierrors.ExitGeneral,
258+
}
210259
}
211260
if closeErr != nil {
212261
_ = os.Remove(tmpPath)
213-
return clierrors.New(fmt.Sprintf("failed to finalize download: %v", closeErr))
262+
return &clierrors.CLIError{
263+
Code: "cli_file_io_error",
264+
Message: fmt.Sprintf("failed to finalize download: %v", closeErr),
265+
Hint: "Could not finalize the file locally. Check free disk space.",
266+
ExitCode: clierrors.ExitGeneral,
267+
}
214268
}
215269

216270
// Atomic rename. On Windows this may fail if dest is open elsewhere;
217271
// os.Rename across filesystems also fails, but temp file is in the
218272
// same directory so this is safe.
219273
if err := os.Rename(tmpPath, dest); err != nil {
220274
_ = os.Remove(tmpPath)
221-
return clierrors.New(fmt.Sprintf("failed to move download to %q: %v", dest, err))
275+
return &clierrors.CLIError{
276+
Code: "cli_file_io_error",
277+
Message: fmt.Sprintf("failed to move download to %q: %v", dest, err),
278+
Hint: "Could not write to the destination path. Check permissions and free disk space.",
279+
ExitCode: clierrors.ExitGeneral,
280+
}
222281
}
223282

224283
return nil

cmd/heygen/video_download_test.go

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

33
import (
44
"encoding/json"
5+
"fmt"
56
"net/http"
67
"net/http/httptest"
78
"os"
@@ -405,11 +406,101 @@ func TestVideoDownload_DownloadFails(t *testing.T) {
405406
if res.ExitCode != 1 {
406407
t.Fatalf("ExitCode = %d, want 1\nstderr: %s", res.ExitCode, res.Stderr)
407408
}
409+
if !strings.Contains(res.Stderr, `"code":"cli_download_failed"`) {
410+
t.Fatalf("stderr should carry cli_download_failed (asset-host 5xx):\n%s", res.Stderr)
411+
}
408412
if _, err := os.Stat(dest); !os.IsNotExist(err) {
409413
t.Fatalf("expected no output file, stat err = %v", err)
410414
}
411415
}
412416

417+
// A presigned asset URL that 403s or 404s is expired/revoked — a client-side
418+
// cli_download_url_expired, distinct from the asset host failing (cli_download_failed).
419+
func TestVideoDownload_ExpiredURL(t *testing.T) {
420+
for _, status := range []int{http.StatusForbidden, http.StatusNotFound} {
421+
t.Run(fmt.Sprintf("HTTP_%d", status), func(t *testing.T) {
422+
tmpDir := t.TempDir()
423+
dest := filepath.Join(tmpDir, "expired.mp4")
424+
425+
var srv *httptest.Server
426+
srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
427+
switch {
428+
case r.Method == http.MethodGet && r.URL.Path == "/v3/videos/vid_123":
429+
writeJSON(t, w, map[string]any{
430+
"data": map[string]any{
431+
"video_url": srv.URL + "/download/expired.mp4",
432+
"status": "completed",
433+
},
434+
})
435+
case r.Method == http.MethodGet && r.URL.Path == "/download/expired.mp4":
436+
w.WriteHeader(status)
437+
default:
438+
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
439+
}
440+
}))
441+
defer srv.Close()
442+
443+
res := runCommand(t, srv.URL, "test-key", "video", "download", "vid_123", "--output-path", dest)
444+
if res.ExitCode != 1 {
445+
t.Fatalf("ExitCode = %d, want 1\nstderr: %s", res.ExitCode, res.Stderr)
446+
}
447+
if !strings.Contains(res.Stderr, `"code":"cli_download_url_expired"`) {
448+
t.Fatalf("stderr should carry cli_download_url_expired (HTTP %d on presigned URL):\n%s", status, res.Stderr)
449+
}
450+
if !strings.Contains(res.Stderr, "video get") {
451+
t.Fatalf("expired-URL hint should point to re-fetching via video get:\n%s", res.Stderr)
452+
}
453+
})
454+
}
455+
}
456+
457+
// The video-get response failing to parse, or yielding an unusable download URL,
458+
// classifies as cli_response_parse_error (not the opaque error code).
459+
func TestVideoDownload_ResponseParseError(t *testing.T) {
460+
t.Run("malformed video-get JSON", func(t *testing.T) {
461+
dest := filepath.Join(t.TempDir(), "x.mp4")
462+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
463+
if r.URL.Path == "/v3/videos/vid_123" {
464+
w.WriteHeader(http.StatusOK)
465+
_, _ = w.Write([]byte("{ this is not valid json"))
466+
return
467+
}
468+
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
469+
}))
470+
defer srv.Close()
471+
472+
res := runCommand(t, srv.URL, "test-key", "video", "download", "vid_123", "--output-path", dest)
473+
if res.ExitCode != 1 {
474+
t.Fatalf("ExitCode = %d, want 1\nstderr: %s", res.ExitCode, res.Stderr)
475+
}
476+
if !strings.Contains(res.Stderr, `"code":"cli_response_parse_error"`) {
477+
t.Fatalf("stderr should carry cli_response_parse_error:\n%s", res.Stderr)
478+
}
479+
})
480+
481+
t.Run("unusable download URL", func(t *testing.T) {
482+
dest := filepath.Join(t.TempDir(), "x.mp4")
483+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
484+
if r.URL.Path == "/v3/videos/vid_123" {
485+
writeJSON(t, w, map[string]any{
486+
"data": map[string]any{"video_url": "http://%zz", "status": "completed"},
487+
})
488+
return
489+
}
490+
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
491+
}))
492+
defer srv.Close()
493+
494+
res := runCommand(t, srv.URL, "test-key", "video", "download", "vid_123", "--output-path", dest)
495+
if res.ExitCode != 1 {
496+
t.Fatalf("ExitCode = %d, want 1\nstderr: %s", res.ExitCode, res.Stderr)
497+
}
498+
if !strings.Contains(res.Stderr, `"code":"cli_response_parse_error"`) {
499+
t.Fatalf("stderr should carry cli_response_parse_error for an unusable URL:\n%s", res.Stderr)
500+
}
501+
})
502+
}
503+
413504
func TestVideoDownload_AuthRequired(t *testing.T) {
414505
srv := setupTestServer(t, map[string]testHandler{})
415506
defer srv.Close()
@@ -429,3 +520,100 @@ func writeJSON(t *testing.T, w http.ResponseWriter, payload any) {
429520
}
430521
_, _ = w.Write(raw)
431522
}
523+
524+
// Transport failure reaching the asset host classifies as network_error (the
525+
// grandfathered shared transport code, intentionally unprefixed).
526+
func TestVideoDownload_NetworkError(t *testing.T) {
527+
dead := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}))
528+
deadURL := dead.URL
529+
dead.Close() // deadURL now refuses connections
530+
531+
dest := filepath.Join(t.TempDir(), "x.mp4")
532+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
533+
if r.URL.Path == "/v3/videos/vid_123" {
534+
writeJSON(t, w, map[string]any{
535+
"data": map[string]any{"video_url": deadURL + "/x.mp4", "status": "completed"},
536+
})
537+
return
538+
}
539+
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
540+
}))
541+
defer srv.Close()
542+
543+
res := runCommand(t, srv.URL, "test-key", "video", "download", "vid_123", "--output-path", dest)
544+
if res.ExitCode != 1 {
545+
t.Fatalf("ExitCode = %d, want 1\nstderr: %s", res.ExitCode, res.Stderr)
546+
}
547+
if !strings.Contains(res.Stderr, `"code":"network_error"`) {
548+
t.Fatalf("stderr should carry network_error (transport failure):\n%s", res.Stderr)
549+
}
550+
}
551+
552+
// A response that promises more bytes than it delivers, then drops, cuts io.Copy
553+
// off mid-stream and classifies as cli_download_interrupted.
554+
func TestVideoDownload_Interrupted(t *testing.T) {
555+
dest := filepath.Join(t.TempDir(), "x.mp4")
556+
var srv *httptest.Server
557+
srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
558+
switch r.URL.Path {
559+
case "/v3/videos/vid_123":
560+
writeJSON(t, w, map[string]any{
561+
"data": map[string]any{"video_url": srv.URL + "/download/x.mp4", "status": "completed"},
562+
})
563+
case "/download/x.mp4":
564+
hj, ok := w.(http.Hijacker)
565+
if !ok {
566+
t.Fatal("test server does not support hijacking")
567+
}
568+
conn, buf, err := hj.Hijack()
569+
if err != nil {
570+
t.Fatalf("hijack: %v", err)
571+
}
572+
// Promise 1000 bytes, send 7, then drop the connection → the client's
573+
// io.Copy hits an unexpected EOF.
574+
_, _ = buf.WriteString("HTTP/1.1 200 OK\r\nContent-Length: 1000\r\n\r\npartial")
575+
_ = buf.Flush()
576+
_ = conn.Close()
577+
default:
578+
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
579+
}
580+
}))
581+
defer srv.Close()
582+
583+
res := runCommand(t, srv.URL, "test-key", "video", "download", "vid_123", "--output-path", dest)
584+
if res.ExitCode != 1 {
585+
t.Fatalf("ExitCode = %d, want 1\nstderr: %s", res.ExitCode, res.Stderr)
586+
}
587+
if !strings.Contains(res.Stderr, `"code":"cli_download_interrupted"`) {
588+
t.Fatalf("stderr should carry cli_download_interrupted (mid-stream cutoff):\n%s", res.Stderr)
589+
}
590+
}
591+
592+
// A destination whose parent directory does not exist fails temp-file creation
593+
// and classifies as cli_file_io_error.
594+
func TestVideoDownload_FileIOError(t *testing.T) {
595+
dest := filepath.Join(t.TempDir(), "no-such-dir", "x.mp4") // parent dir absent
596+
var srv *httptest.Server
597+
srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
598+
switch r.URL.Path {
599+
case "/v3/videos/vid_123":
600+
writeJSON(t, w, map[string]any{
601+
"data": map[string]any{"video_url": srv.URL + "/download/x.mp4", "status": "completed"},
602+
})
603+
case "/download/x.mp4":
604+
w.WriteHeader(http.StatusOK)
605+
_, _ = w.Write([]byte("video-bytes"))
606+
default:
607+
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
608+
}
609+
}))
610+
defer srv.Close()
611+
612+
res := runCommand(t, srv.URL, "test-key", "video", "download", "vid_123", "--output-path", dest)
613+
if res.ExitCode != 1 {
614+
t.Fatalf("ExitCode = %d, want 1\nstderr: %s", res.ExitCode, res.Stderr)
615+
}
616+
if !strings.Contains(res.Stderr, `"code":"cli_file_io_error"`) {
617+
t.Fatalf("stderr should carry cli_file_io_error (temp-create in missing dir):\n%s", res.Stderr)
618+
}
619+
}

0 commit comments

Comments
 (0)