Skip to content
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
## [Unreleased](https://github.com/openfga/go-sdk/compare/v0.7.1...HEAD)

- feat: add contextual tuples support in Expand requests
- fix: 5xx errors were not being properly retried

## v0.7.1

Expand Down
137 changes: 137 additions & 0 deletions api_open_fga_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"sync/atomic"
"testing"
"time"

Expand Down Expand Up @@ -1520,4 +1522,139 @@ func TestOpenFgaApi(t *testing.T) {
t.Fatalf("Expected response code to be INTERNALERRORCODE_INTERNAL_ERROR but actual %s", internalError.ResponseCode())
}
})

t.Run("Retry on 500 error", func(t *testing.T) {
storeID := "01H0H015178Y2V4CX10C2KGHF4"

var attempts int32

// First two attempts return 500, third succeeds.
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
cur := atomic.AddInt32(&attempts, 1)
w.Header().Set("Content-Type", "application/json")
if cur < 3 {
w.WriteHeader(http.StatusInternalServerError)
_, _ = w.Write([]byte(`{"code":"internal_error","message":"transient"}`))
return
}
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"allowed":true}`))
}))
defer server.Close()

cfg, err := NewConfiguration(Configuration{
ApiUrl: server.URL,
RetryParams: &RetryParams{ // allow enough retries (default max is 3, which is fine too)
MaxRetry: 4,
MinWaitInMs: 10, // keep test fast
},
HTTPClient: &http.Client{},
})
if err != nil {
t.Fatalf("failed to create configuration: %v", err)
}

apiClient := NewAPIClient(cfg)

resp, httpResp, reqErr := apiClient.OpenFgaApi.Check(context.Background(), storeID).
Body(CheckRequest{TupleKey: CheckRequestTupleKey{User: "user:anne", Relation: "viewer", Object: "document:doc"}}).
Execute()

if reqErr != nil {
t.Fatalf("expected eventual success after internal error retries, got: %v", reqErr)
}
if httpResp == nil || httpResp.StatusCode != http.StatusOK {
t.Fatalf("expected final HTTP 200, got %+v", httpResp)
}
if resp.Allowed == nil || !*resp.Allowed {
t.Fatalf("expected Allowed true in final response")
}

gotAttempts := int(atomic.LoadInt32(&attempts))
if gotAttempts != 3 { // 1 initial + 2 retries
t.Fatalf("expected 3 attempts (500 retried), got %d", gotAttempts)
}
})

t.Run("Do not retry on 501 error", func(t *testing.T) {
var attempts int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
atomic.AddInt32(&attempts, 1)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusNotImplemented) // 501
_, _ = w.Write([]byte(`{"code":"not_implemented","message":"no retry"}`))
}))
defer server.Close()

cfg, _ := NewConfiguration(Configuration{ApiUrl: server.URL, RetryParams: &RetryParams{MaxRetry: 4, MinWaitInMs: 10}, HTTPClient: &http.Client{}})
apiClient := NewAPIClient(cfg)

_, _, err := apiClient.OpenFgaApi.Check(context.Background(), "store").Body(CheckRequest{TupleKey: CheckRequestTupleKey{User: "u", Relation: "r", Object: "o"}}).Execute()
if err == nil {
t.Fatalf("expected error")
}
if got := int(atomic.LoadInt32(&attempts)); got != 1 {
t.Fatalf("expected 1 attempt, got %d", got)
}
})

t.Run("Retry on 429 error with Retry-After", func(t *testing.T) {
storeID := "01H0H015178Y2V4CX10C2KGHF4"

var attempts int32

// We simulate two 429 responses providing Retry-After, then a success.
retryAfterSeconds := 1.0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
current := atomic.AddInt32(&attempts, 1)
w.Header().Set("Content-Type", "application/json")
if current < 3 { // first two attempts fail with rate limit and Retry-After header
w.Header().Set("Retry-After", "1") // 1 second
w.WriteHeader(http.StatusTooManyRequests)
_, _ = w.Write([]byte(`{"code":"rate_limit_exceeded","message":"Rate limit exceeded, retry after some time"}`))
return
}
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"allowed":true}`))
}))
defer server.Close()

cfg, err := NewConfiguration(Configuration{
ApiUrl: server.URL,
RetryParams: &RetryParams{
MaxRetry: 3,
MinWaitInMs: 5, // this won't be used due to Retry-After headers
},
HTTPClient: &http.Client{},
})
if err != nil {
t.Fatalf("failed to build configuration: %v", err)
}

apiClient := NewAPIClient(cfg)

start := time.Now()
resp, _, reqErr := apiClient.OpenFgaApi.Check(context.Background(), storeID).
Body(CheckRequest{TupleKey: CheckRequestTupleKey{User: "user:anne", Relation: "viewer", Object: "document:doc"}}).
Execute()
elapsed := time.Since(start)

if reqErr != nil {
t.Fatalf("expected success after retries, got error: %v", reqErr)
}
if resp.Allowed == nil || !*resp.Allowed {
t.Fatalf("expected Allowed true in final response")
}

gotAttempts := int(atomic.LoadInt32(&attempts))
if gotAttempts != 3 { // 1 initial + 2 retries
t.Fatalf("expected 3 attempts total, got %d", gotAttempts)
}

// 2xe Retry-After header (1s each) -> expect >= 2s total
minExpected := time.Duration(2*retryAfterSeconds) * time.Second
if elapsed < minExpected {
t.Fatalf("expected elapsed >= %v due to Retry-After headers, got %v", minExpected, elapsed)
}
})
}
5 changes: 2 additions & 3 deletions errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -512,7 +512,6 @@ type FgaApiInternalError struct {

retryAfterDurationInMs int
retryAfterEpoc string
shouldRetry bool
}

// Error returns non-empty string if there was an error.
Expand Down Expand Up @@ -597,7 +596,7 @@ func (e FgaApiInternalError) ShouldRetry() bool {

// GetTimeToWait returns how much time is needed before we can retry
func (e FgaApiInternalError) GetTimeToWait(retryCount int, retryParams retryutils.RetryParams) time.Duration {
if !e.shouldRetry {
if !e.ShouldRetry() {
return time.Duration(0)
}
return retryutils.GetTimeToWait(retryCount, retryParams.MaxRetry, retryParams.MinWaitInMs, e.responseHeader, e.endpointCategory)
Expand Down Expand Up @@ -632,7 +631,7 @@ func NewFgaApiInternalError(
}
}

retryAfter := retryutils.ParseRetryAfterHeaderValue(httpResponse.Header, "Retry-After")
retryAfter := retryutils.ParseRetryAfterHeaderValue(httpResponse.Header, retryutils.RetryAfterHeaderName)
if retryAfter > 0 {
err.retryAfterDurationInMs = int(retryAfter.Milliseconds())
err.retryAfterEpoc = time.Now().Add(retryAfter).Format(time.RFC3339)
Expand Down
Loading