Skip to content
Merged
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
165 changes: 165 additions & 0 deletions shortcuts/wiki/wiki_list_copy_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand Down Expand Up @@ -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)
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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())

Expand Down
73 changes: 68 additions & 5 deletions shortcuts/wiki/wiki_node_copy.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,22 @@

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",
Expand Down Expand Up @@ -81,11 +88,13 @@
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
}
Expand All @@ -108,6 +117,60 @@
},
}

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):
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

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
}

Check warning on line 158 in shortcuts/wiki/wiki_node_copy.go

View check run for this annotation

Codecov / codecov/patch

shortcuts/wiki/wiki_node_copy.go#L157-L158

Added lines #L157 - L158 were not covered by tests
problem, ok := errs.ProblemOf(err)
if !ok {
return err
}

Check warning on line 162 in shortcuts/wiki/wiki_node_copy.go

View check run for this annotation

Codecov / codecov/patch

shortcuts/wiki/wiki_node_copy.go#L161-L162

Added lines #L161 - L162 were not covered by tests
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"]))
Expand Down
11 changes: 7 additions & 4 deletions shortcuts/wiki/wiki_node_get.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -132,17 +134,18 @@ 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 {
return err
}

switch p.Code {
case 99991400:
appendWikiProblemHint(err, wikiNodeGetRateLimitHint)
case 131006:
p.Retryable = false
appendWikiProblemHint(err, wikiPermissionDeniedHint())
Expand Down
36 changes: 36 additions & 0 deletions shortcuts/wiki/wiki_node_get_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down
1 change: 1 addition & 0 deletions skills/lark-wiki/references/lark-wiki-node-copy.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions skills/lark-wiki/references/lark-wiki-node-get.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Loading