Files
larksuite-cli/shortcuts/wiki/wiki_node_create.go
fangshuyu-768 aea9f37f58 feat(wiki): add exponential backoff retry for +node-create lock contention (#1012) (#1076)
When creating wiki nodes under the same parent concurrently, the API
returns error code 131009 (lock contention) ~5-15% of the time. This
adds automatic retry with exponential backoff (250ms, 500ms; max 2
retries) so callers no longer need to implement retry logic themselves.

- Retry loop in runWikiNodeCreate: only retries on code 131009, respects
  context cancellation, prints progress to stderr
- wrapWikiNodeCreateRetryError preserves Err/Raw/Detail.Code in ExitError
- 6 unit tests covering retry success, exhaustion, non-contention error,
  single-retry success, context cancellation, no-retry on success
- 8 dry-run E2E tests for wiki +node-create request shape and validation
2026-05-25 20:03:17 +08:00

587 lines
19 KiB
Go

// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package wiki
import (
"context"
"errors"
"fmt"
"io"
"strings"
"time"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
const (
wikiNodeTypeOrigin = "origin"
wikiNodeTypeShortcut = "shortcut"
wikiMyLibrarySpaceID = "my_library"
wikiResolvedByExplicitSpaceID = "explicit_space_id"
wikiResolvedByParentNode = "parent_node_token"
wikiResolvedByMyLibrary = "my_library"
)
const (
// wikiNodeCreateMaxRetries is the maximum number of retry attempts after
// the initial request when the API returns lock contention (code 131009).
wikiNodeCreateMaxRetries = 2
// wikiNodeCreateRetryBaseDelay is the initial backoff delay for lock
// contention retries. Subsequent retries double the delay (250ms, 500ms).
wikiNodeCreateRetryBaseDelay = 250 * time.Millisecond
)
var wikiObjectTypes = []string{
"sheet",
"mindnote",
"bitable",
"docx",
"slides",
}
// WikiNodeCreate wraps wiki node creation with shortcut-specific ergonomics:
// it can infer the target space from the parent node or the caller's personal
// document library instead of forcing users to pass a numeric space ID first.
var WikiNodeCreate = common.Shortcut{
Service: "wiki",
Command: "+node-create",
Description: "Create a wiki node with automatic space resolution",
Risk: "write",
Scopes: []string{"wiki:node:create", "wiki:node:read", "wiki:space:read"},
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "space-id", Desc: "target wiki space ID; use my_library for the personal document library"},
{Name: "parent-node-token", Desc: "parent wiki node token; if set, the new node is created under that parent"},
{Name: "title", Desc: "node title"},
{Name: "node-type", Default: wikiNodeTypeOrigin, Desc: "node type", Enum: []string{wikiNodeTypeOrigin, wikiNodeTypeShortcut}},
{Name: "obj-type", Default: "docx", Desc: "target object type", Enum: wikiObjectTypes},
{Name: "origin-node-token", Desc: "source node token when --node-type=shortcut"},
},
Tips: []string{
"If --space-id and --parent-node-token are both omitted, user identity falls back to my_library.",
"Use --node-type shortcut --origin-node-token <token> to create a shortcut node.",
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
return validateWikiNodeCreateSpec(readWikiNodeCreateSpec(runtime), runtime.As())
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
dry := buildWikiNodeCreateDryRun(readWikiNodeCreateSpec(runtime))
if runtime.IsBot() {
dry.Desc("After wiki node creation succeeds in bot mode, the CLI will also try to grant the current CLI user full_access (可管理权限) on the new wiki node.")
}
return dry
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
spec := readWikiNodeCreateSpec(runtime)
fmt.Fprintf(runtime.IO().ErrOut, "Creating wiki node...\n")
execution, err := runWikiNodeCreate(ctx, wikiNodeCreateAPI{runtime: runtime}, runtime.As(), spec, runtime.IO().ErrOut)
if err != nil {
return err
}
fmt.Fprintf(runtime.IO().ErrOut, "Created wiki node in space %s via %s.\n", execution.ResolvedSpace.SpaceID, execution.ResolvedSpace.ResolvedBy)
runtime.Out(augmentWikiNodeCreateOutput(runtime, execution), nil)
return nil
},
}
// wikiNodeCreateSpec is the normalized CLI input for the shortcut.
type wikiNodeCreateSpec struct {
SpaceID string
ParentNodeToken string
Title string
NodeType string
ObjType string
OriginNodeToken string
}
// RequestBody converts the normalized shortcut input into the OpenAPI payload.
func (spec wikiNodeCreateSpec) RequestBody() map[string]interface{} {
body := map[string]interface{}{
"node_type": spec.NodeType,
"obj_type": spec.ObjType,
}
if spec.Title != "" {
body["title"] = spec.Title
}
if spec.ParentNodeToken != "" {
body["parent_node_token"] = spec.ParentNodeToken
}
if spec.OriginNodeToken != "" {
body["origin_node_token"] = spec.OriginNodeToken
}
return body
}
// wikiNodeRecord contains the response fields used by the shortcut.
type wikiNodeRecord struct {
SpaceID string
NodeToken string
ObjToken string
ObjType string
ParentNodeToken string
NodeType string
OriginNodeToken string
Title string
HasChild bool
URL string
}
// wikiSpaceRecord contains the response fields used when resolving spaces.
type wikiSpaceRecord struct {
SpaceID string
Name string
SpaceType string
Visibility string
OpenSharing string
}
// wikiResolvedSpace captures both the final numeric space ID and how it was
// derived. Keeping the provenance separate makes the command output easier to
// understand and keeps the resolution logic testable.
type wikiResolvedSpace struct {
SpaceID string
ResolvedBy string
ParentNode *wikiNodeRecord
}
type wikiNodeCreateExecution struct {
Node *wikiNodeRecord
ResolvedSpace wikiResolvedSpace
}
// wikiNodeCreateClient isolates the network operations so the resolution logic
// can be unit-tested without real HTTP calls.
type wikiNodeCreateClient interface {
GetNode(ctx context.Context, token string) (*wikiNodeRecord, error)
GetSpace(ctx context.Context, spaceID string) (*wikiSpaceRecord, error)
CreateNode(ctx context.Context, spaceID string, spec wikiNodeCreateSpec) (*wikiNodeRecord, error)
}
type wikiNodeCreateAPI struct {
runtime *common.RuntimeContext
}
func (api wikiNodeCreateAPI) GetNode(ctx context.Context, token string) (*wikiNodeRecord, error) {
data, err := api.runtime.CallAPI(
"GET",
"/open-apis/wiki/v2/spaces/get_node",
map[string]interface{}{"token": token},
nil,
)
if err != nil {
return nil, err
}
return parseWikiNodeRecord(common.GetMap(data, "node"))
}
func (api wikiNodeCreateAPI) GetSpace(ctx context.Context, spaceID string) (*wikiSpaceRecord, error) {
data, err := api.runtime.CallAPI(
"GET",
fmt.Sprintf("/open-apis/wiki/v2/spaces/%s", validate.EncodePathSegment(spaceID)),
nil,
nil,
)
if err != nil {
return nil, err
}
return parseWikiSpaceRecord(common.GetMap(data, "space"))
}
func (api wikiNodeCreateAPI) CreateNode(ctx context.Context, spaceID string, spec wikiNodeCreateSpec) (*wikiNodeRecord, error) {
data, err := api.runtime.CallAPI(
"POST",
fmt.Sprintf("/open-apis/wiki/v2/spaces/%s/nodes", validate.EncodePathSegment(spaceID)),
nil,
spec.RequestBody(),
)
if err != nil {
return nil, err
}
return parseWikiNodeRecord(common.GetMap(data, "node"))
}
func readWikiNodeCreateSpec(runtime *common.RuntimeContext) wikiNodeCreateSpec {
return wikiNodeCreateSpec{
SpaceID: strings.TrimSpace(runtime.Str("space-id")),
ParentNodeToken: strings.TrimSpace(runtime.Str("parent-node-token")),
Title: strings.TrimSpace(runtime.Str("title")),
NodeType: strings.ToLower(strings.TrimSpace(runtime.Str("node-type"))),
ObjType: strings.ToLower(strings.TrimSpace(runtime.Str("obj-type"))),
OriginNodeToken: strings.TrimSpace(runtime.Str("origin-node-token")),
}
}
func validateWikiNodeCreateSpec(spec wikiNodeCreateSpec, identity core.Identity) error {
if err := validateOptionalResourceName(spec.SpaceID, "--space-id"); err != nil {
return err
}
if err := validateOptionalResourceName(spec.ParentNodeToken, "--parent-node-token"); err != nil {
return err
}
if err := validateOptionalResourceName(spec.OriginNodeToken, "--origin-node-token"); err != nil {
return err
}
if spec.NodeType == wikiNodeTypeShortcut && spec.OriginNodeToken == "" {
return output.ErrValidation("--origin-node-token is required when --node-type=shortcut")
}
if spec.NodeType != wikiNodeTypeShortcut && spec.OriginNodeToken != "" {
return output.ErrValidation("--origin-node-token can only be used when --node-type=shortcut")
}
// Bot identity has no meaningful "personal document library" target, so
// my_library must be rejected explicitly instead of deferring to API-time
// resolution errors.
if identity.IsBot() && spec.SpaceID == wikiMyLibrarySpaceID {
return output.ErrValidation("bot identity does not support --space-id my_library; use an explicit --space-id or --parent-node-token")
}
// Bot identity also cannot fall back implicitly, so it requires an explicit
// target or a parent it can resolve from.
if identity.IsBot() && spec.SpaceID == "" && spec.ParentNodeToken == "" {
return output.ErrValidation("bot identity requires --space-id or --parent-node-token")
}
return nil
}
func buildWikiNodeCreateDryRun(spec wikiNodeCreateSpec) *common.DryRunAPI {
dry := common.NewDryRunAPI()
step := 1
switch {
case needsMyLibraryLookup(spec) && spec.ParentNodeToken != "":
dry.Desc("3-step orchestration: resolve my_library -> resolve parent node -> create wiki node")
case needsMyLibraryLookup(spec):
dry.Desc("2-step orchestration: resolve my_library -> create wiki node")
case spec.ParentNodeToken != "":
dry.Desc("2-step orchestration: resolve parent node -> create wiki node")
default:
dry.Desc("1-step request: create wiki node")
}
if needsMyLibraryLookup(spec) {
dry.GET("/open-apis/wiki/v2/spaces/my_library").
Desc(fmt.Sprintf("[%d] Resolve my_library space ID", step))
step++
}
if spec.ParentNodeToken != "" {
dry.GET("/open-apis/wiki/v2/spaces/get_node").
Desc(fmt.Sprintf("[%d] Resolve parent node space", step)).
Params(map[string]interface{}{"token": spec.ParentNodeToken})
step++
}
dry.POST(fmt.Sprintf("/open-apis/wiki/v2/spaces/%s/nodes", dryRunWikiNodeCreateSpaceID(spec))).
Desc(fmt.Sprintf("[%d] Create wiki node", step)).
Body(spec.RequestBody())
return dry
}
func dryRunWikiNodeCreateSpaceID(spec wikiNodeCreateSpec) string {
if spec.SpaceID != "" && spec.SpaceID != wikiMyLibrarySpaceID {
return spec.SpaceID
}
return "<resolved_space_id>"
}
func needsMyLibraryLookup(spec wikiNodeCreateSpec) bool {
if spec.ParentNodeToken != "" && spec.SpaceID == "" {
return false
}
return spec.SpaceID == "" || spec.SpaceID == wikiMyLibrarySpaceID
}
func runWikiNodeCreate(ctx context.Context, client wikiNodeCreateClient, identity core.Identity, spec wikiNodeCreateSpec, errOut io.Writer) (*wikiNodeCreateExecution, error) {
resolvedSpace, err := resolveWikiNodeCreateSpace(ctx, client, identity, spec)
if err != nil {
return nil, err
}
var (
node *wikiNodeRecord
lastErr error
)
for attempt := 0; attempt <= wikiNodeCreateMaxRetries; attempt++ {
if attempt > 0 {
delay := wikiNodeCreateRetryBaseDelay << uint(attempt-1)
fmt.Fprintf(errOut, "Wiki node create encountered lock contention, retrying (attempt %d/%d) in %v...\n", attempt, wikiNodeCreateMaxRetries, delay)
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-time.After(delay):
}
}
node, lastErr = client.CreateNode(ctx, resolvedSpace.SpaceID, spec)
if lastErr == nil {
break
}
if !isWikiNodeLockContention(lastErr) {
return nil, lastErr
}
}
if lastErr != nil {
return nil, wrapWikiNodeCreateRetryError(lastErr)
}
if node == nil {
return nil, output.Errorf(output.ExitAPI, "api_error", "wiki node create returned no node")
}
return &wikiNodeCreateExecution{
Node: node,
ResolvedSpace: resolvedSpace,
}, nil
}
// isWikiNodeLockContention returns true if the error is a Lark API error with
// code 131009 (wiki node lock contention), which is retryable with backoff.
func isWikiNodeLockContention(err error) bool {
var exitErr *output.ExitError
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
return false
}
return exitErr.Detail.Code == output.LarkErrWikiLockContention
}
// wrapWikiNodeCreateRetryError appends a retry-exhaustion hint to the original
// API error. It builds the ExitError by hand (instead of using ErrWithHint) so
// the original Lark error code survives in the envelope.
func wrapWikiNodeCreateRetryError(err error) error {
if err == nil {
return nil
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
return err
}
hint := fmt.Sprintf(
"wiki node create failed after %d retries due to lock contention; try again later or reduce concurrent node creations under the same parent",
wikiNodeCreateMaxRetries,
)
if existing := strings.TrimSpace(exitErr.Detail.Hint); existing != "" {
hint = existing + "\n" + hint
}
return &output.ExitError{
Code: exitErr.Code,
Detail: &output.ErrDetail{
Type: exitErr.Detail.Type,
Code: exitErr.Detail.Code,
Message: exitErr.Detail.Message,
Hint: hint,
ConsoleURL: exitErr.Detail.ConsoleURL,
Risk: exitErr.Detail.Risk,
Detail: exitErr.Detail.Detail,
},
Err: exitErr.Err,
Raw: exitErr.Raw,
}
}
// resolveWikiNodeCreateSpace applies the shortcut's precedence rules:
// explicit space ID wins, then parent-node inference, then my_library fallback.
func resolveWikiNodeCreateSpace(ctx context.Context, client wikiNodeCreateClient, identity core.Identity, spec wikiNodeCreateSpec) (wikiResolvedSpace, error) {
if spec.SpaceID != "" {
return resolveWikiNodeCreateSpaceFromExplicitSpace(ctx, client, spec)
}
if spec.ParentNodeToken != "" {
return resolveWikiNodeCreateSpaceFromParentNode(ctx, client, spec.ParentNodeToken)
}
if identity.IsBot() {
return wikiResolvedSpace{}, output.ErrValidation("bot identity requires --space-id or --parent-node-token")
}
return resolveWikiNodeCreateSpaceFromMyLibrary(ctx, client)
}
func resolveWikiNodeCreateSpaceFromExplicitSpace(ctx context.Context, client wikiNodeCreateClient, spec wikiNodeCreateSpec) (wikiResolvedSpace, error) {
resolved := wikiResolvedSpace{
SpaceID: spec.SpaceID,
ResolvedBy: wikiResolvedByExplicitSpaceID,
}
if spec.SpaceID == wikiMyLibrarySpaceID {
space, err := client.GetSpace(ctx, wikiMyLibrarySpaceID)
if err != nil {
return wikiResolvedSpace{}, err
}
spaceID, err := requireWikiSpaceID(space)
if err != nil {
return wikiResolvedSpace{}, err
}
resolved.SpaceID = spaceID
resolved.ResolvedBy = wikiResolvedByMyLibrary
}
if spec.ParentNodeToken == "" {
return resolved, nil
}
parent, err := client.GetNode(ctx, spec.ParentNodeToken)
if err != nil {
return wikiResolvedSpace{}, err
}
parentSpaceID, err := requireWikiNodeSpaceID(parent)
if err != nil {
return wikiResolvedSpace{}, err
}
if parentSpaceID != resolved.SpaceID {
return wikiResolvedSpace{}, output.ErrValidation(
"--space-id %q does not match parent node space %q (resolved space: %q)",
spec.SpaceID,
parentSpaceID,
resolved.SpaceID,
)
}
resolved.ParentNode = parent
return resolved, nil
}
func resolveWikiNodeCreateSpaceFromParentNode(ctx context.Context, client wikiNodeCreateClient, parentNodeToken string) (wikiResolvedSpace, error) {
parent, err := client.GetNode(ctx, parentNodeToken)
if err != nil {
return wikiResolvedSpace{}, err
}
spaceID, err := requireWikiNodeSpaceID(parent)
if err != nil {
return wikiResolvedSpace{}, err
}
return wikiResolvedSpace{
SpaceID: spaceID,
ResolvedBy: wikiResolvedByParentNode,
ParentNode: parent,
}, nil
}
func resolveWikiNodeCreateSpaceFromMyLibrary(ctx context.Context, client wikiNodeCreateClient) (wikiResolvedSpace, error) {
space, err := client.GetSpace(ctx, wikiMyLibrarySpaceID)
if err != nil {
return wikiResolvedSpace{}, err
}
spaceID, err := requireWikiSpaceID(space)
if err != nil {
return wikiResolvedSpace{}, err
}
return wikiResolvedSpace{
SpaceID: spaceID,
ResolvedBy: wikiResolvedByMyLibrary,
}, nil
}
func requireWikiNodeSpaceID(node *wikiNodeRecord) (string, error) {
if node != nil && node.SpaceID != "" {
return node.SpaceID, nil
}
return "", output.Errorf(output.ExitAPI, "api_error", "wiki node lookup returned no space_id")
}
func requireWikiSpaceID(space *wikiSpaceRecord) (string, error) {
if space != nil && space.SpaceID != "" {
return space.SpaceID, nil
}
return "", output.ErrValidation("personal document library was not found, please specify --space-id")
}
// resolveMyLibrarySpaceID calls GET /wiki/v2/spaces/my_library and returns
// the per-user real space_id. Shared by shortcuts that accept the my_library
// alias (e.g. +node-create, +node-list) so the behavior stays consistent.
func resolveMyLibrarySpaceID(runtime *common.RuntimeContext) (string, error) {
data, err := runtime.CallAPI(
"GET",
fmt.Sprintf("/open-apis/wiki/v2/spaces/%s", validate.EncodePathSegment(wikiMyLibrarySpaceID)),
nil, nil,
)
if err != nil {
return "", err
}
space, err := parseWikiSpaceRecord(common.GetMap(data, "space"))
if err != nil {
return "", err
}
return requireWikiSpaceID(space)
}
func validateOptionalResourceName(value, flagName string) error {
if value == "" {
return nil
}
if err := validate.ResourceName(value, flagName); err != nil {
return output.ErrValidation("%s", err)
}
return nil
}
func parseWikiNodeRecord(node map[string]interface{}) (*wikiNodeRecord, error) {
if node == nil {
return nil, output.Errorf(output.ExitAPI, "api_error", "wiki node response missing node")
}
return &wikiNodeRecord{
SpaceID: common.GetString(node, "space_id"),
NodeToken: common.GetString(node, "node_token"),
ObjToken: common.GetString(node, "obj_token"),
ObjType: common.GetString(node, "obj_type"),
ParentNodeToken: common.GetString(node, "parent_node_token"),
NodeType: common.GetString(node, "node_type"),
OriginNodeToken: common.GetString(node, "origin_node_token"),
Title: common.GetString(node, "title"),
HasChild: common.GetBool(node, "has_child"),
URL: common.GetString(node, "url"),
}, nil
}
func parseWikiSpaceRecord(space map[string]interface{}) (*wikiSpaceRecord, error) {
if space == nil {
return nil, output.Errorf(output.ExitAPI, "api_error", "wiki space response missing space")
}
return &wikiSpaceRecord{
SpaceID: common.GetString(space, "space_id"),
Name: common.GetString(space, "name"),
SpaceType: common.GetString(space, "space_type"),
Visibility: common.GetString(space, "visibility"),
OpenSharing: common.GetString(space, "open_sharing"),
}, nil
}
func wikiNodeCreateOutput(execution *wikiNodeCreateExecution) map[string]interface{} {
node := execution.Node
return map[string]interface{}{
"resolved_space_id": execution.ResolvedSpace.SpaceID,
"resolved_by": execution.ResolvedSpace.ResolvedBy,
"space_id": node.SpaceID,
"node_token": node.NodeToken,
"obj_token": node.ObjToken,
"obj_type": node.ObjType,
"node_type": node.NodeType,
"title": node.Title,
"parent_node_token": node.ParentNodeToken,
"origin_node_token": node.OriginNodeToken,
"has_child": node.HasChild,
}
}
func augmentWikiNodeCreateOutput(runtime *common.RuntimeContext, execution *wikiNodeCreateExecution) map[string]interface{} {
if execution == nil || execution.Node == nil {
return map[string]interface{}{}
}
out := wikiNodeCreateOutput(execution)
if grant := common.AutoGrantCurrentUserDrivePermission(runtime, execution.Node.NodeToken, "wiki"); grant != nil {
out["permission_grant"] = grant
}
if u := wikiNodeURL(runtime.Config.Brand, execution.Node); u != "" {
out["url"] = u
}
return out
}