Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: fix and improve rate limit handling. #176

Merged
merged 2 commits into from
Mar 14, 2025
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
9 changes: 8 additions & 1 deletion .openapi-generator/FILES
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ docs/AbortedMessageResponse.md
docs/Any.md
docs/Assertion.md
docs/AssertionTupleKey.md
docs/AuthErrorCode.md
docs/AuthorizationModel.md
docs/CheckRequest.md
docs/CheckRequestTupleKey.md
Expand All @@ -43,6 +44,7 @@ docs/ExpandRequest.md
docs/ExpandRequestTupleKey.md
docs/ExpandResponse.md
docs/FgaObject.md
docs/ForbiddenResponse.md
docs/GetStoreResponse.md
docs/InternalErrorCode.md
docs/InternalErrorMessageResponse.md
Expand Down Expand Up @@ -111,13 +113,17 @@ example/opentelemetry/main.go
git_push.sh
go.mod
go.sum
internal/utils/randomtime.go
internal/utils/retryutils/retryparams.go
internal/utils/retryutils/retryparams_test.go
internal/utils/retryutils/retryutils.go
internal/utils/retryutils/retryutils_test.go
internal/utils/ulid.go
internal/utils/ulid_test.go
model_aborted_message_response.go
model_any.go
model_assertion.go
model_assertion_tuple_key.go
model_auth_error_code.go
model_authorization_model.go
model_check_request.go
model_check_request_tuple_key.go
Expand All @@ -136,6 +142,7 @@ model_expand_request.go
model_expand_request_tuple_key.go
model_expand_response.go
model_fga_object.go
model_forbidden_response.go
model_get_store_response.go
model_internal_error_code.go
model_internal_error_message_response.go
Expand Down
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
# Changelog

## [Unreleased](https://github.com/openfga/go-sdk/compare/v0.6.5...HEAD)

- feat: fix and improve retries and rate limit handling. (#176)
The SDK now retries on network errors and the default retry handling has been fixed
for both the calls to the OpenFGA API and the API Token Issuer for those using ClientCredentials
The SDK now also respects the rate limit headers (`Retry-After`) returned by the server and will retry the request after the specified time.
If the header is not sent or on network errors, it will fall back to exponential backoff.

## v0.6.5

### [0.6.5](https://github.com/openfga/go-sdk/compare/v0.6.4...v0.6.5) (2025-02-06)
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1061,7 +1061,7 @@ If you have found a bug or if you have a feature request, please report them on

### Pull Requests

While we accept Pull Requests on this repository, the SDKs are autogenerated so please consider additionally submitting your Pull Requests to the [sdk-generator](https://github.com/openfga/sdk-generator) and linking the two PRs together and to the corresponding issue. This will greatly assist the OpenFGA team in being able to give timely reviews as well as deploying fixes and updates to our other SDKs as well.
While we accept Pull Requests on this repository, the SDKs are autogenerated so please consider additionally submitting your Pull Requests to the [sdk-generator](https://github.com/openfga/sdk-generator) and linking the two PRs together and to the corresponding issue. This will greatly assist the OpenFGA team in being able to give timely reviews as well as deploying fixes and updates to our other SDKs as well.

## Author

Expand Down
3 changes: 2 additions & 1 deletion api_client.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import (
"time"
"unicode/utf8"

"github.com/openfga/go-sdk/internal/utils/retryutils"
"github.com/openfga/go-sdk/telemetry"
)

Expand Down Expand Up @@ -74,7 +75,7 @@ func NewAPIClient(cfg *Configuration) *APIClient {
} else {
cfg.Credentials.Context = context.Background()
telemetry.Bind(cfg.Credentials.Context, telemetry.Get(telemetry.TelemetryFactoryParameters{Configuration: cfg.Telemetry}))
var httpClient, headers = cfg.Credentials.GetHttpClientAndHeaderOverrides()
var httpClient, headers = cfg.Credentials.GetHttpClientAndHeaderOverrides(retryutils.GetRetryParamsOrDefault(cfg.RetryParams))
if len(headers) > 0 {
for idx := range headers {
cfg.AddDefaultHeader(headers[idx].Key, headers[idx].Value)
Expand Down
1,376 changes: 776 additions & 600 deletions api_open_fga.go

Large diffs are not rendered by default.

173 changes: 172 additions & 1 deletion api_open_fga_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,9 @@ import (
"time"

"github.com/jarcoal/httpmock"

"github.com/openfga/go-sdk/credentials"
"github.com/openfga/go-sdk/internal/utils/retryutils"
)

type TestDefinition struct {
Expand Down Expand Up @@ -61,6 +63,126 @@ func TestOpenFgaApiConfiguration(t *testing.T) {
}
})

t.Run("RetryParams should be valid", func(t *testing.T) {
tests := []struct {
retryParams *RetryParams
expectedError bool
}{
{
retryParams: &RetryParams{
MaxRetry: 1,
MinWaitInMs: 0,
},
expectedError: true,
},
{
retryParams: &RetryParams{
MaxRetry: 100,
MinWaitInMs: 1,
},
expectedError: true,
},
{
retryParams: &RetryParams{
MaxRetry: -1,
MinWaitInMs: 1,
},
expectedError: true,
},
{
retryParams: &RetryParams{
MaxRetry: 1,
MinWaitInMs: -1,
},
expectedError: true,
},
{
retryParams: &RetryParams{
MaxRetry: 1,
MinWaitInMs: -1,
},
expectedError: true,
},
{
retryParams: &RetryParams{
MaxRetry: 1,
MinWaitInMs: 1,
},
expectedError: false,
},
{
retryParams: &RetryParams{
MaxRetry: 0,
MinWaitInMs: 1,
},
expectedError: false,
},
}

for _, test := range tests {
t.Run(fmt.Sprintf("RetryParams: %v", *test.retryParams), func(t *testing.T) {
config, err := NewConfiguration(Configuration{
ApiUrl: "https://api.fga.example",
RetryParams: test.retryParams,
})

if test.expectedError && err == nil {
t.Fatalf("Expected an error when RetryParams are invalid, got none")
}

if !test.expectedError && err != nil {
t.Fatalf("Unexpected error: %v", err)
}

if !test.expectedError {
if config.RetryParams == nil {
t.Fatalf("Expected RetryParams on the config to be non-nil")
}

appliedRetryParams := *config.RetryParams
if appliedRetryParams.MaxRetry != test.retryParams.MaxRetry {
t.Fatalf("Expected MaxRetry to be %v, got %v", test.retryParams.MaxRetry, appliedRetryParams.MaxRetry)
}

if appliedRetryParams.MinWaitInMs != test.retryParams.MinWaitInMs {
t.Fatalf("Expected MinWaitInMs to be %v, got %v", test.retryParams.MinWaitInMs, appliedRetryParams.MinWaitInMs)
}

appliedRetryParams = config.GetRetryParams()
if appliedRetryParams.MaxRetry != test.retryParams.MaxRetry {
t.Fatalf("Expected MaxRetry to be %v, got %v", test.retryParams.MaxRetry, appliedRetryParams.MaxRetry)
}

if appliedRetryParams.MinWaitInMs != test.retryParams.MinWaitInMs {
t.Fatalf("Expected MinWaitInMs to be %v, got %v", test.retryParams.MinWaitInMs, appliedRetryParams.MinWaitInMs)
}
}
})
}
})

t.Run("RetryParams is default if not set", func(t *testing.T) {
config, err := NewConfiguration(Configuration{
ApiUrl: "https://api.fga.example",
})
if err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if config.RetryParams == nil {
t.Fatalf("Expected RetryParams on the config to be non-nil")
}

appliedRetryParams := config.GetRetryParams()
defaultParams := retryutils.GetRetryParamsOrDefault(nil)
if appliedRetryParams.MaxRetry != defaultParams.MaxRetry {
t.Fatalf("Expected MaxRetry to be %v, got %v", defaultParams.MaxRetry, appliedRetryParams.MaxRetry)
}

if appliedRetryParams.MinWaitInMs != defaultParams.MinWaitInMs {
t.Fatalf("Expected MinWaitInMs to be %v, got %v", defaultParams.MinWaitInMs, appliedRetryParams.MinWaitInMs)
}
})

t.Run("In ApiToken credential method, apiToken is required in the Credentials Config", func(t *testing.T) {
_, err := NewConfiguration(Configuration{
ApiHost: "https://api.fga.example",
Expand Down Expand Up @@ -1256,7 +1378,7 @@ func TestOpenFgaApi(t *testing.T) {
updatedConfiguration, err := NewConfiguration(Configuration{
ApiHost: "api.fga.example",
RetryParams: &RetryParams{
MaxRetry: 2,
MaxRetry: 3,
MinWaitInMs: 5,
},
})
Expand Down Expand Up @@ -1286,6 +1408,55 @@ func TestOpenFgaApi(t *testing.T) {
}
})

t.Run("Check with initial 429 but eventually resolved with default config", func(t *testing.T) {
test := TestDefinition{
Name: "Check",
JsonResponse: `{"allowed":true, "resolution":""}`,
ResponseStatus: 200,
Method: "POST",
RequestPath: "check",
}
requestBody := CheckRequest{
TupleKey: CheckRequestTupleKey{
User: "user:81684243-9356-4421-8fbf-a4f8d36aa31b",
Relation: "viewer",
Object: "document:0192ab2a-d83f-756d-9397-c5ed9f3cb69a",
},
}

var expectedResponse CheckResponse
if err := json.Unmarshal([]byte(test.JsonResponse), &expectedResponse); err != nil {
t.Fatalf("%v", err)
}

httpmock.Activate()
defer httpmock.DeactivateAndReset()
firstMock := httpmock.NewStringResponder(429, "")
secondMock, _ := httpmock.NewJsonResponder(200, expectedResponse)
httpmock.RegisterResponder(test.Method, fmt.Sprintf("%s/stores/%s/%s", configuration.ApiUrl, "01GXSB9YR785C4FYS3C0RTG7B2", test.RequestPath),
firstMock.Then(secondMock),
)

got, response, err := apiClient.OpenFgaApi.Check(context.Background(), "01GXSB9YR785C4FYS3C0RTG7B2").Body(requestBody).Execute()

if err != nil {
t.Fatalf("%v", err)
}

if response.StatusCode != test.ResponseStatus {
t.Fatalf("OpenFga%v().Execute() = %v, want %v", test.Name, response.StatusCode, test.ResponseStatus)
}

responseJson, err := got.MarshalJSON()
if err != nil {
t.Fatalf("%v", err)
}

if *got.Allowed != *expectedResponse.Allowed {
t.Fatalf("OpenFga%v().Execute() = %v, want %v", test.Name, string(responseJson), test.JsonResponse)
}
})

t.Run("Check with 500 error", func(t *testing.T) {
test := TestDefinition{
Name: "Check",
Expand Down
3 changes: 2 additions & 1 deletion client/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,12 @@ import (
_nethttp "net/http"
"time"

"golang.org/x/sync/errgroup"

fgaSdk "github.com/openfga/go-sdk"
"github.com/openfga/go-sdk/credentials"
internalutils "github.com/openfga/go-sdk/internal/utils"
"github.com/openfga/go-sdk/telemetry"
"golang.org/x/sync/errgroup"
)

var (
Expand Down
33 changes: 16 additions & 17 deletions configuration.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import (
"net/http"

"github.com/openfga/go-sdk/credentials"
"github.com/openfga/go-sdk/internal/utils/retryutils"
"github.com/openfga/go-sdk/telemetry"
)

Expand All @@ -25,11 +26,8 @@ const (
defaultUserAgent = "openfga-sdk go/0.6.5"
)

// RetryParams configures configuration for retry in case of HTTP too many request
type RetryParams struct {
MaxRetry int `json:"maxRetry,omitempty"`
MinWaitInMs int `json:"minWaitInMs,omitempty"`
}
// RetryParams provides configuration for retries in case of server errors
type RetryParams = retryutils.RetryParams

// Configuration stores the configuration of the API client
type Configuration struct {
Expand All @@ -49,14 +47,6 @@ type Configuration struct {
Telemetry *telemetry.Configuration `json:"telemetry,omitempty"`
}

// DefaultRetryParams returns the default retry parameters
func DefaultRetryParams() *RetryParams {
return &RetryParams{
MaxRetry: 3,
MinWaitInMs: 100,
}
}

func GetSdkUserAgent() string {
return defaultUserAgent
}
Expand Down Expand Up @@ -98,15 +88,24 @@ func NewConfiguration(config Configuration) (*Configuration, error) {
cfg.Telemetry = telemetry.DefaultTelemetryConfiguration()
}

err := cfg.ValidateConfig()

retryParams, err := retryutils.NewRetryParams(cfg.RetryParams)
if err != nil {
return nil, err
}
cfg.RetryParams = retryParams

if err := cfg.ValidateConfig(); err != nil {
return nil, err
}

return cfg, nil
}

// GetRetryParams
func (c *Configuration) GetRetryParams() RetryParams {
return retryutils.GetRetryParamsOrDefault(c.RetryParams)
}

// AddDefaultHeader adds a new HTTP header to the default header in the request
func (c *Configuration) AddDefaultHeader(key string, value string) {
c.DefaultHeaders[key] = value
Expand All @@ -128,8 +127,8 @@ func (c *Configuration) ValidateConfig() error {
}
}

if c.RetryParams != nil && c.RetryParams.MaxRetry > 15 {
return reportError("Configuration.RetryParams.MaxRetry exceeds maximum allowed limit of 15")
if err := c.RetryParams.Validate(); err != nil {
return err
}

return nil
Expand Down
4 changes: 3 additions & 1 deletion credentials/credentials.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"net/url"
"strings"

"github.com/openfga/go-sdk/internal/utils/retryutils"
"github.com/openfga/go-sdk/oauth2/clientcredentials"
)

Expand Down Expand Up @@ -99,7 +100,7 @@ func (c *Credentials) GetApiTokenHeader() *HeaderParams {
// GetHttpClientAndHeaderOverrides
// The main export the client uses to get a configuration with the necessary
// httpClient and header overrides based on the chosen credential method
func (c *Credentials) GetHttpClientAndHeaderOverrides() (*http.Client, []*HeaderParams) {
func (c *Credentials) GetHttpClientAndHeaderOverrides(retryParams retryutils.RetryParams) (*http.Client, []*HeaderParams) {
var headers []*HeaderParams
var client = http.DefaultClient
switch c.Method {
Expand All @@ -108,6 +109,7 @@ func (c *Credentials) GetHttpClientAndHeaderOverrides() (*http.Client, []*Header
ClientID: c.Config.ClientCredentialsClientId,
ClientSecret: c.Config.ClientCredentialsClientSecret,
TokenURL: c.Config.ClientCredentialsApiTokenIssuer,
RetryParams: retryParams,
}
if c.Config.ClientCredentialsApiAudience != "" {
ccConfig.EndpointParams = map[string][]string{
Expand Down
Loading