diff --git a/api/client/scim/scim.go b/api/client/scim/scim.go index d587b79998a..4446f2c139c 100644 --- a/api/client/scim/scim.go +++ b/api/client/scim/scim.go @@ -16,14 +16,46 @@ package scim import ( "context" + "strconv" "github.com/gravitational/trace" "google.golang.org/grpc" + "google.golang.org/grpc/metadata" "google.golang.org/protobuf/types/known/emptypb" scimpb "github.com/gravitational/teleport/api/gen/proto/go/teleport/scim/v1" ) +// RateLimitError is returned by SCIM client methods when the server signals a +// rate or concurrency limit. It wraps a [trace.LimitExceededError] and carries +// the retry-after delay in seconds extracted from the gRPC response trailer. +type RateLimitError struct { + // RetryAfterSeconds is the value of the "retry-after" gRPC trailer, or 0 if + // the trailer was absent. + RetryAfterSeconds int64 + // Err is the underlying LimitExceeded trace error. + Err error +} + +func (e *RateLimitError) Error() string { return e.Err.Error() } +func (e *RateLimitError) Unwrap() error { return e.Err } + +// wrapRateLimitErr converts a gRPC error into a [*RateLimitError] when the +// error is a limit-exceeded error, extracting the retry-after value from the +// supplied trailer. +func wrapRateLimitErr(trailer metadata.MD, err error) error { + if err == nil || !trace.IsLimitExceeded(err) { + return err + } + rlErr := &RateLimitError{Err: err} + if vals := trailer.Get("retry-after"); len(vals) > 0 { + if n, parseErr := strconv.ParseInt(vals[0], 10, 64); parseErr == nil { + rlErr.RetryAfterSeconds = n + } + } + return rlErr +} + // Client wraps the underlying GRPC client with some more human-friendly tooling type Client struct { grpcClient scimpb.SCIMServiceClient @@ -37,30 +69,33 @@ func NewClient(grpcClient scimpb.SCIMServiceClient) *Client { return &Client{grpcClient: grpcClient} } -// List fetches all (or a subset of all) resources resources of a given type +// ListSCIMResources fetches resources of a given type. func (c *Client) ListSCIMResources(ctx context.Context, req *scimpb.ListSCIMResourcesRequest) (*scimpb.ResourceList, error) { - resp, err := c.grpcClient.ListSCIMResources(ctx, req) + var trailer metadata.MD + resp, err := c.grpcClient.ListSCIMResources(ctx, req, grpc.Trailer(&trailer)) if err != nil { - return nil, trace.Wrap(err, "handling SCIM list request") + return nil, trace.Wrap(wrapRateLimitErr(trailer, err), "handling SCIM list request") } return resp, nil } // GetSCIMResource fetches a single SCIM resource from the server by name func (c *Client) GetSCIMResource(ctx context.Context, req *scimpb.GetSCIMResourceRequest) (*scimpb.Resource, error) { - resp, err := c.grpcClient.GetSCIMResource(ctx, req) + var trailer metadata.MD + resp, err := c.grpcClient.GetSCIMResource(ctx, req, grpc.Trailer(&trailer)) if err != nil { - return nil, trace.Wrap(err, "handling SCIM get request") + return nil, trace.Wrap(wrapRateLimitErr(trailer, err), "handling SCIM get request") } return resp, nil } -// CreateSCIResource creates a new SCIM resource based on a supplied +// CreateSCIMResource creates a new SCIM resource based on a supplied // resource description func (c *Client) CreateSCIMResource(ctx context.Context, req *scimpb.CreateSCIMResourceRequest) (*scimpb.Resource, error) { - resp, err := c.grpcClient.CreateSCIMResource(ctx, req) + var trailer metadata.MD + resp, err := c.grpcClient.CreateSCIMResource(ctx, req, grpc.Trailer(&trailer)) if err != nil { - return nil, trace.Wrap(err, "handling SCIM create request") + return nil, trace.Wrap(wrapRateLimitErr(trailer, err), "handling SCIM create request") } return resp, nil } @@ -68,27 +103,30 @@ func (c *Client) CreateSCIMResource(ctx context.Context, req *scimpb.CreateSCIMR // UpdateSCIMResource handles a request to update a resource, returning a // representation of the updated resource func (c *Client) UpdateSCIMResource(ctx context.Context, req *scimpb.UpdateSCIMResourceRequest) (*scimpb.Resource, error) { - res, err := c.grpcClient.UpdateSCIMResource(ctx, req) + var trailer metadata.MD + res, err := c.grpcClient.UpdateSCIMResource(ctx, req, grpc.Trailer(&trailer)) if err != nil { - return nil, trace.Wrap(err, "handling SCIM update request") + return nil, trace.Wrap(wrapRateLimitErr(trailer, err), "handling SCIM update request") } return res, nil } // DeleteSCIMResource handles a request to delete a resource. func (c *Client) DeleteSCIMResource(ctx context.Context, req *scimpb.DeleteSCIMResourceRequest) (*emptypb.Empty, error) { - res, err := c.grpcClient.DeleteSCIMResource(ctx, req) + var trailer metadata.MD + res, err := c.grpcClient.DeleteSCIMResource(ctx, req, grpc.Trailer(&trailer)) if err != nil { - return nil, trace.Wrap(err, "handling SCIM delete request") + return nil, trace.Wrap(wrapRateLimitErr(trailer, err), "handling SCIM delete request") } return res, nil } // PatchSCIMResource handles a request to patch a resource. func (c *Client) PatchSCIMResource(ctx context.Context, request *scimpb.PatchSCIMResourceRequest) (*scimpb.Resource, error) { - resp, err := c.grpcClient.PatchSCIMResource(ctx, request) + var trailer metadata.MD + resp, err := c.grpcClient.PatchSCIMResource(ctx, request, grpc.Trailer(&trailer)) if err != nil { - return nil, trace.Wrap(err, "handling SCIM patch request") + return nil, trace.Wrap(wrapRateLimitErr(trailer, err), "handling SCIM patch request") } return resp, nil } diff --git a/api/client/scim/scim_test.go b/api/client/scim/scim_test.go new file mode 100644 index 00000000000..c67b9385999 --- /dev/null +++ b/api/client/scim/scim_test.go @@ -0,0 +1,65 @@ +// Copyright 2026 Gravitational, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package scim + +import ( + "testing" + + "github.com/gravitational/trace" + "github.com/stretchr/testify/require" + "google.golang.org/grpc/metadata" +) + +func TestWrapRateLimitErr(t *testing.T) { + t.Run("nil error passthrough", func(t *testing.T) { + got := wrapRateLimitErr(metadata.MD{}, nil) + require.NoError(t, got) + }) + + t.Run("non-limit error passthrough", func(t *testing.T) { + err := trace.NotFound("not found") + got := wrapRateLimitErr(metadata.MD{}, err) + require.ErrorIs(t, got, err) + require.NotErrorAs(t, err, new(*RateLimitError)) + }) + + t.Run("limit exceeded without retry-after trailer", func(t *testing.T) { + err := trace.LimitExceeded("too many requests") + got := wrapRateLimitErr(metadata.MD{}, err) + var rlErr *RateLimitError + require.ErrorAs(t, got, &rlErr) + require.Equal(t, int64(0), rlErr.RetryAfterSeconds) + require.True(t, trace.IsLimitExceeded(got)) + }) + + t.Run("limit exceeded with retry-after trailer", func(t *testing.T) { + err := trace.LimitExceeded("too many requests") + trailer := metadata.MD{"retry-after": []string{"42"}} + got := wrapRateLimitErr(trailer, err) + var rlErr *RateLimitError + require.ErrorAs(t, got, &rlErr) + require.Equal(t, int64(42), rlErr.RetryAfterSeconds) + require.True(t, trace.IsLimitExceeded(got)) + }) + + t.Run("limit exceeded with unparseable retry-after trailer", func(t *testing.T) { + err := trace.LimitExceeded("too many requests") + trailer := metadata.MD{"retry-after": []string{"not-a-number"}} + got := wrapRateLimitErr(trailer, err) + var rlErr *RateLimitError + require.ErrorAs(t, got, &rlErr) + require.Equal(t, int64(0), rlErr.RetryAfterSeconds) + }) +}