Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 78 additions & 4 deletions shortcuts/doc/doc_errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,17 @@ import (
"net/http"
"strings"

larkcore "github.com/larksuite/oapi-sdk-go/v3/core"

"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/shortcuts/common"
)

const (
docMediaAppScopeHint = "stop retrying now; ask the app developer to apply for the required scope(s), and retry only after they have been approved and enabled"
docMediaRateLimitHint = "the request was rate limited; stop immediate retries and retry later using exponential backoff with jitter"
)

// wrapDocNetworkErr returns err unchanged when it is already a typed errs.*
// error (preserving its subtype / code / log_id from the runtime boundary),
// and only wraps a raw, unclassified error as a transport-level network error.
Expand All @@ -23,6 +30,57 @@ func wrapDocNetworkErr(err error, format string, args ...any) error {
return errs.NewNetworkError(errs.SubtypeNetworkTransport, format, args...).WithCause(err)
}

// classifyDocMediaStreamError recovers the two business errors this shortcut
// promises to guide from DoStream's current "HTTP <status>: <JSON>" transport
// message. This compatibility shim is deliberately local to Doc media reads:
// expand it only if DoStream exposes a structured response body or another Doc
// streaming command adopts the same recovery contract.
func classifyDocMediaStreamError(runtime *common.RuntimeContext, err error) error {
problem, ok := errs.ProblemOf(err)
if runtime == nil || !ok || problem == nil ||
problem.Category != errs.CategoryNetwork ||
problem.Subtype != errs.SubtypeNetworkTransport ||
problem.Code < http.StatusBadRequest {
return err
}

prefix := fmt.Sprintf("HTTP %d: ", problem.Code)
if !strings.HasPrefix(problem.Message, prefix) {
return err
}
body := strings.TrimSpace(strings.TrimPrefix(problem.Message, prefix))
if !strings.HasPrefix(body, "{") {
return err
}

header := make(http.Header)
header.Set("Content-Type", "application/json")
if problem.LogID != "" {
header.Set(larkcore.HttpHeaderKeyLogId, problem.LogID)
}
_, classified := runtime.ClassifyAPIResponse(&larkcore.ApiResp{
StatusCode: problem.Code,
Header: header,
RawBody: []byte(body),
})
classifiedProblem, classifiedOK := errs.ProblemOf(classified)
if !classifiedOK || classifiedProblem == nil ||
(classifiedProblem.Code != 99991672 && classifiedProblem.Code != 99991400) {
return err
}

var permissionErr *errs.PermissionError
if errors.As(classified, &permissionErr) {
permissionErr.WithCause(err)
return classified
}
var apiErr *errs.APIError
if errors.As(classified, &apiErr) {
apiErr.WithCause(err)
}
return classified
}

// withDocMediaDownloadRecoveryHint keeps the final download error intact while
// adding recovery guidance for media permission and throttling failures.
// Whiteboard downloads use a different API and must not be redirected to the
Expand All @@ -41,15 +99,31 @@ func withDocMediaDownloadRecoveryHint(err error, mediaType string) error {
hint := fmt.Sprintf("Direct document media download returned HTTP 403. To preview the image or file content, try `lark-cli docs +media-preview --token %s --output <path>`.", tokenArg)
appendDocRecoveryHint(problem, hint)
}
if problem.Code == 99991672 && !strings.Contains(problem.Hint, "stop retrying now") {
appendDocRecoveryHint(problem, docMediaAppScopeHint)
}

if docMediaDownloadIsRateLimit(problem) && !strings.Contains(problem.Hint, "exponential backoff") {
const hint = "Document media download was rate limited; stop immediate retries and retry later with exponential backoff."
appendDocRecoveryHint(problem, hint)
if docMediaIsRateLimit(problem) && !strings.Contains(problem.Hint, "exponential backoff") {
appendDocRecoveryHint(problem, docMediaRateLimitHint)
}
return err
}

func withDocMediaPreviewRecoveryHint(err error) error {
problem, ok := errs.ProblemOf(err)
if !ok || problem == nil {
return err
}
if problem.Code == 99991672 && !strings.Contains(problem.Hint, "stop retrying now") {
appendDocRecoveryHint(problem, docMediaAppScopeHint)
}
if docMediaIsRateLimit(problem) && !strings.Contains(problem.Hint, "exponential backoff") {
appendDocRecoveryHint(problem, docMediaRateLimitHint)
}
return err
}

func docMediaDownloadIsRateLimit(problem *errs.Problem) bool {
func docMediaIsRateLimit(problem *errs.Problem) bool {
return problem.Subtype == errs.SubtypeRateLimit ||
problem.Code == 99991400 ||
problem.Code == http.StatusTooManyRequests
Expand Down
1 change: 1 addition & 0 deletions shortcuts/doc/doc_media_download.go
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ var DocMediaDownload = common.Shortcut{
ApiPath: apiPath,
})
if err != nil {
err = classifyDocMediaStreamError(runtime, err)
return withDocMediaDownloadRecoveryHint(wrapDocNetworkErr(err, "download failed: %v", err), mediaType)
}
defer resp.Body.Close()
Expand Down
3 changes: 2 additions & 1 deletion shortcuts/doc/doc_media_preview.go
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,8 @@ var DocMediaPreview = common.Shortcut{
},
})
if err != nil {
return wrapDocNetworkErr(err, "preview failed: %v", err)
err = classifyDocMediaStreamError(runtime, err)
return withDocMediaPreviewRecoveryHint(wrapDocNetworkErr(err, "preview failed: %v", err))
}
defer resp.Body.Close()

Expand Down
148 changes: 136 additions & 12 deletions shortcuts/doc/doc_media_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"os"
Expand All @@ -25,6 +26,8 @@ import (
"github.com/larksuite/cli/shortcuts/common"
)

const expectedDocMediaRateLimitHint = "the request was rate limited; stop immediate retries and retry later using exponential backoff with jitter"

func docsTestConfigWithAppID(appID string) *core.CliConfig {
return &core.CliConfig{
AppID: appID, AppSecret: "test-secret", Brand: core.BrandFeishu,
Expand Down Expand Up @@ -737,10 +740,8 @@ func TestDocMediaDownloadHTTP429SuggestsBackoff(t *testing.T) {
if !ok || problem.Category != errs.CategoryNetwork || problem.Code != http.StatusTooManyRequests {
t.Fatalf("problem=%+v ok=%v, want network HTTP 429", problem, ok)
}
for _, want := range []string{"stop immediate retries", "retry later with exponential backoff"} {
if !strings.Contains(problem.Hint, want) {
t.Fatalf("hint=%q, want %q", problem.Hint, want)
}
if problem.Hint != expectedDocMediaRateLimitHint {
t.Fatalf("hint=%q, want generic rate-limit hint %q", problem.Hint, expectedDocMediaRateLimitHint)
}
if strings.Contains(problem.Hint, "1 minute") {
t.Fatalf("hint=%q, want no fixed retry duration", problem.Hint)
Expand Down Expand Up @@ -774,16 +775,48 @@ func TestDocMediaDownloadExportAuthRateLimitPreservesAPIErrorAndSuggestsBackoff(
if problem.LogID != "log-doc-auth-limited" || !problem.Retryable {
t.Fatalf("problem=%+v, want preserved log_id and retryable", problem)
}
for _, want := range []string{"stop immediate retries", "retry later with exponential backoff"} {
if !strings.Contains(problem.Hint, want) {
t.Fatalf("hint=%q, want %q", problem.Hint, want)
}
if problem.Hint != expectedDocMediaRateLimitHint {
t.Fatalf("hint=%q, want generic rate-limit hint %q", problem.Hint, expectedDocMediaRateLimitHint)
}
if strings.Contains(problem.Hint, "1 minute") {
t.Fatalf("hint=%q, want no fixed retry duration", problem.Hint)
}
}

func TestDocMediaDownloadExportAuthAppScopeAddsStopRetryingHint(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-auth-scope-app"))
reg.Register(&httpmock.Stub{
Method: http.MethodGet,
URL: "/open-apis/drive/v1/permissions/media_auth_scope/members/auth",
Body: map[string]interface{}{
"code": 99991672,
"msg": "app scope not enabled",
"error": map[string]interface{}{
"permission_violations": []interface{}{
map[string]interface{}{"subject": "docs:document.media:download"},
},
},
},
})

withDocsWorkingDir(t, t.TempDir())
err := mountAndRunDocs(t, DocMediaDownload, []string{
"+media-download",
"--token", "media_auth_scope",
"--output", "blocked.bin",
"--as", "bot",
}, f, nil)
var permissionErr *errs.PermissionError
if !errors.As(err, &permissionErr) || permissionErr.Code != 99991672 || permissionErr.Subtype != errs.SubtypeAppScopeNotApplied {
t.Fatalf("error=%T %v, want authorization/app_scope_not_applied/99991672", err, err)
}
for _, want := range []string{"developer console", "stop retrying now", "retry only after", "approved and enabled"} {
if !strings.Contains(permissionErr.Hint, want) {
t.Fatalf("hint=%q, want %q", permissionErr.Hint, want)
}
}
}

func TestDocMediaDownloadTypedRateLimitSuggestsBackoff(t *testing.T) {
err := errs.NewAPIError(errs.SubtypeRateLimit, "request trigger frequency limit").
WithCode(99991400).
Expand All @@ -798,16 +831,107 @@ func TestDocMediaDownloadTypedRateLimitSuggestsBackoff(t *testing.T) {
if problem.Category != errs.CategoryAPI || problem.Subtype != errs.SubtypeRateLimit || problem.Code != 99991400 || !problem.Retryable {
t.Fatalf("problem=%+v, want preserved API rate-limit metadata", problem)
}
for _, want := range []string{"upstream hint", "stop immediate retries", "retry later with exponential backoff"} {
if !strings.Contains(problem.Hint, want) {
t.Fatalf("hint=%q, want %q", problem.Hint, want)
}
if want := "upstream hint\n" + expectedDocMediaRateLimitHint; problem.Hint != want {
t.Fatalf("hint=%q, want %q", problem.Hint, want)
}
if strings.Contains(problem.Hint, "1 minute") {
t.Fatalf("hint=%q, want no fixed retry duration", problem.Hint)
}
}

func TestDocMediaStreamCommandsClassifyLarkRecoveryErrors(t *testing.T) {
commands := []struct {
name string
shortcut common.Shortcut
args []string
endpoint string
exportAuth bool
}{
{
name: "media download",
shortcut: DocMediaDownload,
args: []string{"+media-download", "--token", "media_stream_error", "--output", "blocked.bin", "--as", "bot"},
endpoint: "/open-apis/drive/v1/medias/media_stream_error/download",
exportAuth: true,
},
{
name: "media preview",
shortcut: DocMediaPreview,
args: []string{"+media-preview", "--token", "media_stream_error", "--output", "blocked.bin", "--as", "bot"},
endpoint: "/open-apis/drive/v1/medias/media_stream_error/preview_download",
},
}
errorsToClassify := []struct {
name string
code int
}{
{name: "app scope not applied", code: 99991672},
{name: "rate limited", code: 99991400},
}

for _, command := range commands {
for _, apiFailure := range errorsToClassify {
t.Run(command.name+"/"+apiFailure.name, func(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-stream-error-app"))
if command.exportAuth {
registerDocMediaExportAuth(reg, "media_stream_error", true)
}
reg.Register(&httpmock.Stub{
Method: http.MethodGet,
URL: command.endpoint,
Status: http.StatusBadRequest,
Body: map[string]interface{}{
"code": apiFailure.code,
"msg": "media stream API error",
"error": map[string]interface{}{
"permission_violations": []interface{}{
map[string]interface{}{"subject": "docs:document.media:download"},
},
},
},
})

withDocsWorkingDir(t, t.TempDir())
err := mountAndRunDocs(t, command.shortcut, command.args, f, nil)
problem, ok := errs.ProblemOf(err)
if !ok || problem.Code != apiFailure.code {
t.Fatalf("problem=%+v ok=%v, want code=%d", problem, ok, apiFailure.code)
}
var streamErr *errs.NetworkError
if !errors.As(err, &streamErr) || streamErr.Code != http.StatusBadRequest {
t.Fatalf("error chain=%T %v, want original HTTP 400 network error preserved as cause", err, err)
}

switch apiFailure.code {
case 99991672:
var permissionErr *errs.PermissionError
if !errors.As(err, &permissionErr) || permissionErr.Subtype != errs.SubtypeAppScopeNotApplied {
t.Fatalf("error=%T %v, want authorization/app_scope_not_applied", err, err)
}
if len(permissionErr.MissingScopes) != 1 || permissionErr.MissingScopes[0] != "docs:document.media:download" {
t.Fatalf("missing_scopes=%v, want docs:document.media:download", permissionErr.MissingScopes)
}
if permissionErr.ConsoleURL == "" || !strings.Contains(permissionErr.Hint, "developer console") {
t.Fatalf("console_url=%q hint=%q, want developer-console recovery", permissionErr.ConsoleURL, permissionErr.Hint)
}
for _, want := range []string{"stop retrying now", "retry only after", "approved and enabled"} {
if !strings.Contains(permissionErr.Hint, want) {
t.Fatalf("hint=%q, want %q", permissionErr.Hint, want)
}
}
case 99991400:
if problem.Category != errs.CategoryAPI || problem.Subtype != errs.SubtypeRateLimit || !problem.Retryable {
t.Fatalf("problem=%+v, want retryable api/rate_limit", problem)
}
if problem.Hint != expectedDocMediaRateLimitHint {
t.Fatalf("hint=%q, want generic rate-limit hint %q", problem.Hint, expectedDocMediaRateLimitHint)
}
}
})
}
}
}

func TestDocMediaDownloadAppendsExtensionFromContentDispositionFilename(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-download-disposition-app"))
registerDocMediaExportAuth(reg, "tok_123", true)
Expand Down
1 change: 1 addition & 0 deletions shortcuts/drive/drive_download.go
Original file line number Diff line number Diff line change
Expand Up @@ -255,6 +255,7 @@ var DriveDownload = common.Shortcut{
ApiPath: fmt.Sprintf("/open-apis/drive/v1/files/%s/download", validate.EncodePathSegment(fileToken)),
})
if err != nil {
err = classifyDriveFileReadStreamError(runtime, err)
return withDriveDownloadRecoveryHint(wrapDriveNetworkErr(err, "download failed: %s", err), fileToken)
}
defer resp.Body.Close()
Expand Down
Loading
Loading