diff --git a/shortcuts/wiki/wiki_list_copy_test.go b/shortcuts/wiki/wiki_list_copy_test.go index 04548ff3d0..0644fe1ecd 100644 --- a/shortcuts/wiki/wiki_list_copy_test.go +++ b/shortcuts/wiki/wiki_list_copy_test.go @@ -4,17 +4,22 @@ package wiki import ( + "bytes" + "context" "encoding/json" "errors" + "io" "net/http" "net/url" "reflect" "strings" "testing" + "time" "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/cmdutil" "github.com/larksuite/cli/internal/httpmock" + "github.com/larksuite/cli/internal/output" "github.com/larksuite/cli/shortcuts/common" ) @@ -591,6 +596,166 @@ func TestWikiNodeCopyDeclaresNodeOnlySemantics(t *testing.T) { } } +func TestRunWikiNodeCopyRetriesLockContentionThenSucceeds(t *testing.T) { + t.Parallel() + + lockErr := errs.NewAPIError(errs.SubtypeConflict, "lock contention"). + WithCode(output.LarkErrWikiLockContention). + WithRetryable() + calls := 0 + var stderr bytes.Buffer + data, err := runWikiNodeCopyWithRetry(context.Background(), &stderr, 0, func() (map[string]interface{}, error) { + calls++ + if calls == 1 { + return nil, lockErr + } + return map[string]interface{}{"node": map[string]interface{}{"node_token": "wik_copied"}}, nil + }) + if err != nil { + t.Fatalf("runWikiNodeCopyWithRetry() error = %v", err) + } + if calls != 2 { + t.Fatalf("calls = %d, want 2", calls) + } + if common.GetString(common.GetMap(data, "node"), "node_token") != "wik_copied" { + t.Fatalf("data = %#v, want copied node", data) + } + if !strings.Contains(stderr.String(), "retrying (attempt 1/2)") { + t.Fatalf("stderr = %q, want retry progress", stderr.String()) + } +} + +func TestRunWikiNodeCopyDoesNotRetryOtherErrors(t *testing.T) { + t.Parallel() + + cause := errors.New("permission denied") + otherErr := errs.NewPermissionError(errs.SubtypePermissionDenied, "no access").WithCause(cause) + calls := 0 + _, err := runWikiNodeCopyWithRetry(context.Background(), io.Discard, 0, func() (map[string]interface{}, error) { + calls++ + return nil, otherErr + }) + if err == nil { + t.Fatal("expected error") + } + if calls != 1 { + t.Fatalf("calls = %d, want 1", calls) + } + problem, ok := errs.ProblemOf(err) + if !ok { + t.Fatalf("error = %T, want typed problem", err) + } + if problem.Category != errs.CategoryAuthorization || problem.Subtype != errs.SubtypePermissionDenied { + t.Fatalf("problem = %#v, want authorization/permission_denied", problem) + } + if !errors.Is(err, cause) { + t.Fatalf("error does not preserve cause %v: %v", cause, err) + } +} + +func TestRunWikiNodeCopyBackoffCancellationPreservesErrorContract(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + ctx context.Context + wantCause error + wantSubtype errs.Subtype + }{ + { + name: "canceled", + ctx: canceledContext(), + wantCause: context.Canceled, + wantSubtype: errs.SubtypeNetworkTransport, + }, + { + name: "deadline exceeded", + ctx: expiredContext(), + wantCause: context.DeadlineExceeded, + wantSubtype: errs.SubtypeNetworkTimeout, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + lockErr := errs.NewAPIError(errs.SubtypeConflict, "lock contention"). + WithCode(output.LarkErrWikiLockContention). + WithRetryable() + calls := 0 + _, err := runWikiNodeCopyWithRetry(tc.ctx, io.Discard, time.Hour, func() (map[string]interface{}, error) { + calls++ + return nil, lockErr + }) + if err == nil { + t.Fatal("expected error") + } + if calls != 1 { + t.Fatalf("calls = %d, want 1", calls) + } + problem, ok := errs.ProblemOf(err) + if !ok { + t.Fatalf("error = %T, want typed problem", err) + } + if problem.Category != errs.CategoryNetwork || problem.Subtype != tc.wantSubtype || problem.Retryable { + t.Fatalf("problem = %#v, want non-retryable network/%s", problem, tc.wantSubtype) + } + if !errors.Is(err, tc.wantCause) { + t.Fatalf("error does not preserve cause %v: %v", tc.wantCause, err) + } + }) + } +} + +func canceledContext() context.Context { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + return ctx +} + +func expiredContext() context.Context { + ctx, cancel := context.WithDeadline(context.Background(), time.Now().Add(-time.Second)) + cancel() + return ctx +} + +func TestRunWikiNodeCopyRetryExhaustionPreservesErrorContract(t *testing.T) { + t.Parallel() + + cause := errors.New("upstream lock cause") + const upstreamHint = "upstream recovery hint" + lockErr := errs.NewAPIError(errs.SubtypeConflict, "lock contention"). + WithCode(output.LarkErrWikiLockContention). + WithRetryable(). + WithHint(upstreamHint). + WithCause(cause) + calls := 0 + _, err := runWikiNodeCopyWithRetry(context.Background(), io.Discard, 0, func() (map[string]interface{}, error) { + calls++ + return nil, lockErr + }) + if err == nil { + t.Fatal("expected error") + } + if calls != wikiNodeCopyMaxRetries+1 { + t.Fatalf("calls = %d, want %d", calls, wikiNodeCopyMaxRetries+1) + } + problem, ok := errs.ProblemOf(err) + if !ok { + t.Fatalf("error = %T, want typed problem", err) + } + if problem.Category != errs.CategoryAPI || problem.Subtype != errs.SubtypeConflict || problem.Code != output.LarkErrWikiLockContention || !problem.Retryable { + t.Fatalf("problem = %#v, want retryable API conflict %d", problem, output.LarkErrWikiLockContention) + } + if !strings.Contains(problem.Hint, upstreamHint+"\n") || !strings.Contains(problem.Hint, "failed after 2 retries") { + t.Fatalf("hint = %q, want upstream and retry-exhaustion guidance", problem.Hint) + } + if !errors.Is(err, cause) { + t.Fatalf("error does not preserve cause %v: %v", cause, err) + } +} + func TestWikiNodeCopyCopiesNodeToTargetSpace(t *testing.T) { t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) diff --git a/shortcuts/wiki/wiki_node_copy.go b/shortcuts/wiki/wiki_node_copy.go index eb5c25a281..a6632515be 100644 --- a/shortcuts/wiki/wiki_node_copy.go +++ b/shortcuts/wiki/wiki_node_copy.go @@ -5,15 +5,22 @@ package wiki import ( "context" + "errors" "fmt" "io" "strings" + "time" "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/validate" "github.com/larksuite/cli/shortcuts/common" ) +const ( + wikiNodeCopyMaxRetries = 2 + wikiNodeCopyRetryBaseDelay = 250 * time.Millisecond +) + // WikiNodeCopy copies a wiki node into a target space or under a target parent node. var WikiNodeCopy = common.Shortcut{ Service: "wiki", @@ -81,11 +88,13 @@ var WikiNodeCopy = common.Shortcut{ fmt.Fprintf(runtime.IO().ErrOut, "Copying wiki node %s from space %s\n", common.MaskToken(nodeToken), common.MaskToken(spaceID)) - data, err := runtime.CallAPITyped("POST", - fmt.Sprintf("/open-apis/wiki/v2/spaces/%s/nodes/%s/copy", - validate.EncodePathSegment(spaceID), - validate.EncodePathSegment(nodeToken)), - nil, buildNodeCopyBody(runtime)) + apiPath := fmt.Sprintf("/open-apis/wiki/v2/spaces/%s/nodes/%s/copy", + validate.EncodePathSegment(spaceID), + validate.EncodePathSegment(nodeToken)) + body := buildNodeCopyBody(runtime) + data, err := runWikiNodeCopyWithRetry(ctx, runtime.IO().ErrOut, wikiNodeCopyRetryBaseDelay, func() (map[string]interface{}, error) { + return runtime.CallAPITyped("POST", apiPath, nil, body) + }) if err != nil { return err } @@ -108,6 +117,60 @@ var WikiNodeCopy = common.Shortcut{ }, } +func runWikiNodeCopyWithRetry(ctx context.Context, errOut io.Writer, baseDelay time.Duration, call func() (map[string]interface{}, error)) (map[string]interface{}, error) { + var lastErr error + for attempt := 0; attempt <= wikiNodeCopyMaxRetries; attempt++ { + if attempt > 0 { + delay := baseDelay << uint(attempt-1) + fmt.Fprintf(errOut, "Wiki node copy encountered lock contention, retrying (attempt %d/%d) in %v...\n", attempt, wikiNodeCopyMaxRetries, delay) + select { + case <-ctx.Done(): + return nil, wikiNodeCopyBackoffContextError(ctx.Err()) + case <-time.After(delay): + } + } + + data, err := call() + if err == nil { + return data, nil + } + lastErr = err + if !isWikiNodeLockContention(err) { + return nil, err + } + } + return nil, wrapWikiNodeCopyRetryError(lastErr) +} + +func wikiNodeCopyBackoffContextError(err error) error { + subtype := errs.SubtypeNetworkTransport + message := "wiki node copy retry was canceled during lock-contention backoff" + if errors.Is(err, context.DeadlineExceeded) { + subtype = errs.SubtypeNetworkTimeout + message = "wiki node copy retry deadline exceeded during lock-contention backoff" + } + return errs.NewNetworkError(subtype, "%s", message).WithCause(err) +} + +func wrapWikiNodeCopyRetryError(err error) error { + if err == nil { + return nil + } + problem, ok := errs.ProblemOf(err) + if !ok { + return err + } + hint := fmt.Sprintf( + "wiki node copy failed after %d retries due to lock contention; try again later or reduce concurrent writes under the same target parent", + wikiNodeCopyMaxRetries, + ) + if existing := strings.TrimSpace(problem.Hint); existing != "" { + hint = existing + "\n" + hint + } + problem.Hint = hint + return err +} + func renderWikiNodeCopyPretty(w io.Writer, out map[string]interface{}) { fmt.Fprintf(w, "Copied node:\n") fmt.Fprintf(w, " title: %s\n", valueOrDash(out["title"])) diff --git a/shortcuts/wiki/wiki_node_get.go b/shortcuts/wiki/wiki_node_get.go index e7c72fb74e..20d0ade2a0 100644 --- a/shortcuts/wiki/wiki_node_get.go +++ b/shortcuts/wiki/wiki_node_get.go @@ -17,6 +17,8 @@ import ( "github.com/spf13/cobra" ) +const wikiNodeGetRateLimitHint = "Do not retry immediately. Wait retry_after_seconds, or use exponential backoff with jitter. Stop after 3 total attempts (1 initial + 2 retries)." + // wikiNodeGetURLObjTypes maps a Lark URL path prefix (slash-bounded) to the // obj_type the wiki get_node API expects when the token is an obj_token. // /wiki/ is handled separately because node_tokens take no obj_type. @@ -132,10 +134,9 @@ var WikiNodeGet = common.Shortcut{ } // wikiNodeGetProblem adds command-specific classification and recovery for -// get_node business errors that are intentionally not registered in the -// process-wide code metadata table. These failures are terminal for the same -// input: callers must change the resource token or operation instead of -// retrying the request or switching identities. +// get_node errors that need command-specific recovery. Terminal business +// errors require a changed token, operation, or permission; rate limiting +// remains retryable only within the bounded backoff guidance below. func wikiNodeGetProblem(err error) error { p, ok := errs.ProblemOf(err) if !ok { @@ -143,6 +144,8 @@ func wikiNodeGetProblem(err error) error { } switch p.Code { + case 99991400: + appendWikiProblemHint(err, wikiNodeGetRateLimitHint) case 131006: p.Retryable = false appendWikiProblemHint(err, wikiPermissionDeniedHint()) diff --git a/shortcuts/wiki/wiki_node_get_test.go b/shortcuts/wiki/wiki_node_get_test.go index 447b767343..b2722168a7 100644 --- a/shortcuts/wiki/wiki_node_get_test.go +++ b/shortcuts/wiki/wiki_node_get_test.go @@ -528,6 +528,42 @@ func TestWikiNodeGetMountedExplainsResourcePermissionDenied(t *testing.T) { } } +func TestWikiNodeGetProblemBoundsRateLimitRetries(t *testing.T) { + t.Parallel() + + cause := errors.New("opaque upstream cause") + const upstreamHint = "upstream pacing hint" + err := errs.NewAPIError(errs.SubtypeRateLimit, "opaque upstream message"). + WithCode(99991400). + WithRetryable(). + WithRetryAfterSeconds(8). + WithHint(upstreamHint). + WithCause(cause) + + got := wikiNodeGetProblem(err) + p, ok := errs.ProblemOf(got) + if !ok { + t.Fatalf("ProblemOf() ok=false") + } + if p.Category != errs.CategoryAPI || p.Subtype != errs.SubtypeRateLimit || p.Code != 99991400 || !p.Retryable { + t.Fatalf("problem = %#v, want retryable api/rate_limit/99991400", p) + } + var apiErr *errs.APIError + if !errors.As(got, &apiErr) { + t.Fatalf("error = %T, want *errs.APIError", got) + } + if apiErr.RetryAfterSeconds != 8 { + t.Fatalf("retry_after_seconds = %d, want 8", apiErr.RetryAfterSeconds) + } + wantHint := upstreamHint + "\n" + wikiNodeGetRateLimitHint + if p.Hint != wantHint { + t.Fatalf("hint = %q, want %q", p.Hint, wantHint) + } + if !errors.Is(got, cause) { + t.Fatalf("error does not preserve cause %v: %v", cause, got) + } +} + func TestWikiNodeGetProblemPreservesPermissionErrorContract(t *testing.T) { t.Parallel() diff --git a/skills/lark-wiki/references/lark-wiki-node-copy.md b/skills/lark-wiki/references/lark-wiki-node-copy.md index e0895233bf..3e0557b72b 100644 --- a/skills/lark-wiki/references/lark-wiki-node-copy.md +++ b/skills/lark-wiki/references/lark-wiki-node-copy.md @@ -50,6 +50,7 @@ lark-cli wiki +node-copy \ - Copying is non-recursive: only the requested node and its content are copied. - Descendant nodes must be copied separately. +- When the Wiki service returns `131009` lock contention, the CLI retries twice with bounded exponential backoff. If contention remains, wait before retrying again and avoid concurrent writes under the same target parent. - To move an existing Wiki node without keeping the source, use [`wiki +move`](lark-wiki-move.md) instead of copy-then-delete. ## Required Scope diff --git a/skills/lark-wiki/references/lark-wiki-node-get.md b/skills/lark-wiki/references/lark-wiki-node-get.md index 8a42c45c1f..09d734af55 100644 --- a/skills/lark-wiki/references/lark-wiki-node-get.md +++ b/skills/lark-wiki/references/lark-wiki-node-get.md @@ -63,6 +63,10 @@ These HTTP 200 responses carry a non-zero business code and are not retryable wi | `131013` | The resource token is invalid | Do not switch identity or reauthorize; correct the URL/token | | `131014` | The document is not mounted in Wiki | Stop Wiki resolution; use the corresponding docs/sheets/base/drive command, or provide a Wiki URL/node_token | +## Rate limiting + +For `99991400` / `rate_limit`: Do not retry immediately. Wait `retry_after_seconds`, or use exponential backoff with jitter. Stop after 3 total attempts (1 initial + 2 retries). + ## Required Scope `wiki:node:retrieve`