From 830a4a60562596a200f25e536f146388aa6f8b14 Mon Sep 17 00:00:00 2001 From: Bjorn Date: Tue, 7 Apr 2026 12:57:51 -0700 Subject: [PATCH 1/2] fix: paginate usergroup grants to avoid rate limit cascades The Grants method for usergroups was fetching all group members then calling users.info for each one sequentially in a single call. For large groups this exhausts the Slack rate limit, and on retry the SDK restarts the entire loop from member 0, hitting the limit at the same point. Now processes members in batches of 20 per page. When a rate limit error occurs mid-batch, the SDK retries just that batch. Completed batches are not re-fetched. Co-Authored-By: Claude Opus 4.6 (1M context) --- pkg/connector/user_group.go | 33 ++++++++++++++++++++++++++++++--- 1 file changed, 30 insertions(+), 3 deletions(-) diff --git a/pkg/connector/user_group.go b/pkg/connector/user_group.go index 04066c31..5c84d6cc 100644 --- a/pkg/connector/user_group.go +++ b/pkg/connector/user_group.go @@ -3,6 +3,7 @@ package connector import ( "context" "fmt" + "strconv" v2 "github.com/conductorone/baton-sdk/pb/c1/connector/v2" "github.com/conductorone/baton-sdk/pkg/annotations" @@ -14,6 +15,8 @@ import ( "github.com/slack-go/slack" ) +const userGroupGrantPageSize = 20 + type userGroupResourceType struct { resourceType *v2.ResourceType client *slack.Client @@ -129,7 +132,7 @@ func (o *userGroupResourceType) Entitlements( func (o *userGroupResourceType) Grants( ctx context.Context, res *v2.Resource, - _ resource.SyncOpAttrs, + attrs resource.SyncOpAttrs, ) ( []*v2.Grant, *resource.SyncOpResults, @@ -141,8 +144,26 @@ func (o *userGroupResourceType) Grants( return nil, &resource.SyncOpResults{Annotations: outputAnnotations}, client.WrapError(err, fmt.Sprintf("fetching user group members for group %s", res.Id.Resource), &outputAnnotations) } + // Parse the page offset from the token. On the first call this is 0. + offset := 0 + if attrs.PageToken.Token != "" { + offset, err = strconv.Atoi(attrs.PageToken.Token) + if err != nil { + return nil, nil, fmt.Errorf("baton-slack: parsing page token: %w", err) + } + } + + // Group membership may have changed between pages. + if offset >= len(groupMembers) { + return nil, &resource.SyncOpResults{Annotations: outputAnnotations}, nil + } + + // Slice the member list for this page. + end := min(offset+userGroupGrantPageSize, len(groupMembers)) + page := groupMembers[offset:end] + var rv []*v2.Grant - for _, member := range groupMembers { + for _, member := range page { user, err := o.client.GetUserInfoContext(ctx, member) if err != nil { return nil, &resource.SyncOpResults{Annotations: outputAnnotations}, client.WrapError(err, fmt.Sprintf("fetching user info for member %s", member), &outputAnnotations) @@ -156,5 +177,11 @@ func (o *userGroupResourceType) Grants( rv = append(rv, grant) } - return rv, &resource.SyncOpResults{Annotations: outputAnnotations}, nil + // If there are more members, return a token so the SDK calls us again. + var nextPageToken string + if end < len(groupMembers) { + nextPageToken = strconv.Itoa(end) + } + + return rv, &resource.SyncOpResults{NextPageToken: nextPageToken, Annotations: outputAnnotations}, nil } From d2e7c0f10d37f1c2df60ec07bad0adfe3fedde52 Mon Sep 17 00:00:00 2001 From: Bjorn Date: Tue, 7 Apr 2026 13:09:48 -0700 Subject: [PATCH 2/2] fix: preserve gRPC error code in WrapError fallthrough WrapError was re-wrapping all unrecognized errors as codes.Unknown, including errors from businessPlusClient/uhttp that already had a proper gRPC code (e.g. codes.Unavailable for 429s). This prevented the SDK from retrying these errors. Now checks if the error already has a gRPC code and preserves it. Co-Authored-By: Claude Opus 4.6 (1M context) --- pkg/connector/client/helpers.go | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/pkg/connector/client/helpers.go b/pkg/connector/client/helpers.go index 2f305e88..debb5891 100644 --- a/pkg/connector/client/helpers.go +++ b/pkg/connector/client/helpers.go @@ -16,6 +16,7 @@ import ( "github.com/slack-go/slack" "go.uber.org/zap" "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" "google.golang.org/protobuf/types/known/timestamppb" ) @@ -88,6 +89,11 @@ func WrapError(err error, contextMsg string, annos *annotations.Annotations) err return uhttp.WrapErrors(grpcCode, contextMsg, err) } + // Preserve the gRPC code if the error already has one (e.g. from uhttp/businessPlusClient). + // Only fall back to Unknown if no code is set. + if code := status.Code(err); code != codes.OK { + return uhttp.WrapErrors(code, contextMsg, err) + } return uhttp.WrapErrors(codes.Unknown, contextMsg, err) }