SCIM Client rate limiting error (#67313)

* SCIM Client rate limiting error

* update
This commit is contained in:
Marek Smoliński
2026-06-09 12:23:29 +00:00
committed by GitHub
parent 42829df6c5
commit 0404d46d73
2 changed files with 117 additions and 14 deletions
+52 -14
View File
@@ -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
}
+65
View File
@@ -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)
})
}