Skip to content

Commit 203581d

Browse files
committed
feat: retry IBM Cloud Global Catalog calls on HTTP 429
Add a shared DoWithRetry helper to handle IBM Cloud API rate limiting (HTTP 429) using exponential backoff and honoring the Retry-After header when present. Apply the retry wrapper to GlobalCatalog client call sites: - GetCatalogEntryWithContext - ListCatalogEntriesWithContext - GetPricing Add unit tests covering: - successful call without retry - non-429 passthrough - retry then success - retry exhaustion - context cancellation - Retry-After handling without real sleep Signed-off-by: Anand Nekkunti <anand.nekkunti@ibm.com>
1 parent 18e7a4b commit 203581d

File tree

3 files changed

+201
-3
lines changed

3 files changed

+201
-3
lines changed

pkg/cloudprovider/ibm/catalog.go

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,9 @@ func (c *GlobalCatalogClient) GetInstanceType(ctx context.Context, id string) (*
8585
Include: core.StringPtr("*"), // Include all fields
8686
}
8787

88-
entry, _, err := c.client.GetCatalogEntryWithContext(ctx, options)
88+
entry, err := DoWithRetry(ctx, func() (*globalcatalogv1.CatalogEntry, *core.DetailedResponse, error) {
89+
return c.client.GetCatalogEntryWithContext(ctx, options)
90+
})
8991
if err != nil {
9092
return nil, fmt.Errorf("getting catalog entry: %w", err)
9193
}
@@ -114,7 +116,9 @@ func (c *GlobalCatalogClient) ListInstanceTypes(ctx context.Context) ([]globalca
114116
Limit: &limit,
115117
}
116118

117-
result, _, err := c.client.ListCatalogEntriesWithContext(ctx, options)
119+
result, err := DoWithRetry(ctx, func() (*globalcatalogv1.EntrySearchResult, *core.DetailedResponse, error) {
120+
return c.client.ListCatalogEntriesWithContext(ctx, options)
121+
})
118122
if err != nil {
119123
return nil, fmt.Errorf("listing catalog entries: %w", err)
120124
}
@@ -147,7 +151,9 @@ func (c *GlobalCatalogClient) GetPricing(ctx context.Context, catalogEntryID str
147151
ID: &catalogEntryID,
148152
}
149153

150-
pricingData, _, err := sdkClient.GetPricing(pricingOptions)
154+
pricingData, err := DoWithRetry(ctx, func() (*globalcatalogv1.PricingGet, *core.DetailedResponse, error) {
155+
return sdkClient.GetPricing(pricingOptions)
156+
})
151157
if err != nil {
152158
return nil, fmt.Errorf("calling GetPricing API: %w", err)
153159
}

pkg/cloudprovider/ibm/retry.go

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
/*
2+
Copyright The Kubernetes Authors.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
package ibm
17+
18+
import (
19+
"context"
20+
"fmt"
21+
"net/http"
22+
"strconv"
23+
"time"
24+
25+
"github.com/IBM/go-sdk-core/v5/core"
26+
)
27+
28+
// DoWithRetry handles HTTP 429 rate limiting with exponential backoff.
29+
// It retries up to 5 times, respecting the Retry-After header if present.
30+
func DoWithRetry[T any](ctx context.Context, fn func() (T, *core.DetailedResponse, error)) (T, error) {
31+
var zero T
32+
backoff := 100 * time.Millisecond // initialBackoff
33+
maxRetries := 5
34+
35+
for attempt := 0; attempt < maxRetries; attempt++ {
36+
result, response, err := fn()
37+
38+
// Success or non-rate-limit error
39+
if response == nil || response.StatusCode != http.StatusTooManyRequests {
40+
return result, err
41+
}
42+
43+
// Use Retry-After for this sleep only; keep exponential backoff progression separate.
44+
delay := backoff
45+
if response.Headers != nil {
46+
if ra := response.Headers.Get("Retry-After"); ra != "" {
47+
if secs, parseErr := strconv.Atoi(ra); parseErr == nil && secs > 0 {
48+
delay = time.Duration(secs) * time.Second
49+
}
50+
}
51+
}
52+
53+
select {
54+
case <-ctx.Done():
55+
return zero, ctx.Err()
56+
case <-time.After(delay):
57+
backoff = min(backoff*2, 30*time.Second) // maxBackoff 30 Sec
58+
}
59+
}
60+
61+
return zero, fmt.Errorf("rate limited after %d retries", maxRetries)
62+
}
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
/*
2+
Copyright The Kubernetes Authors.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
package ibm
17+
18+
import (
19+
"context"
20+
"net/http"
21+
"testing"
22+
23+
"github.com/IBM/go-sdk-core/v5/core"
24+
"github.com/stretchr/testify/assert"
25+
"github.com/stretchr/testify/require"
26+
)
27+
28+
func TestDoWithRetry_SuccessFirstTry(t *testing.T) {
29+
ctx := context.Background()
30+
calls := 0
31+
result, err := DoWithRetry(ctx, func() (string, *core.DetailedResponse, error) {
32+
calls++
33+
return "ok", &core.DetailedResponse{StatusCode: 200}, nil
34+
})
35+
require.NoError(t, err)
36+
assert.Equal(t, "ok", result)
37+
assert.Equal(t, 1, calls)
38+
}
39+
40+
func TestDoWithRetry_Non429ErrorReturnsImmediately(t *testing.T) {
41+
ctx := context.Background()
42+
calls := 0
43+
apiErr := assert.AnError
44+
result, err := DoWithRetry(ctx, func() (string, *core.DetailedResponse, error) {
45+
calls++
46+
return "", &core.DetailedResponse{StatusCode: 500}, apiErr
47+
})
48+
require.Error(t, err)
49+
assert.ErrorIs(t, err, apiErr)
50+
assert.Equal(t, "", result)
51+
assert.Equal(t, 1, calls)
52+
}
53+
54+
func TestDoWithRetry_NilResponseReturnsImmediately(t *testing.T) {
55+
ctx := context.Background()
56+
calls := 0
57+
result, err := DoWithRetry(ctx, func() (string, *core.DetailedResponse, error) {
58+
calls++
59+
return "ok", nil, nil
60+
})
61+
require.NoError(t, err)
62+
assert.Equal(t, "ok", result)
63+
assert.Equal(t, 1, calls)
64+
}
65+
66+
func TestDoWithRetry_429ThenSuccess(t *testing.T) {
67+
ctx := context.Background()
68+
calls := 0
69+
result, err := DoWithRetry(ctx, func() (int, *core.DetailedResponse, error) {
70+
calls++
71+
if calls == 1 {
72+
return 0, &core.DetailedResponse{StatusCode: 429}, nil
73+
}
74+
return 42, &core.DetailedResponse{StatusCode: 200}, nil
75+
})
76+
require.NoError(t, err)
77+
assert.Equal(t, 42, result)
78+
assert.Equal(t, 2, calls)
79+
}
80+
81+
func TestDoWithRetry_ExhaustedRetries(t *testing.T) {
82+
ctx := context.Background()
83+
calls := 0
84+
result, err := DoWithRetry(ctx, func() (string, *core.DetailedResponse, error) {
85+
calls++
86+
return "", &core.DetailedResponse{StatusCode: 429}, nil
87+
})
88+
require.Error(t, err)
89+
assert.Contains(t, err.Error(), "rate limited after 5 retries")
90+
assert.Equal(t, "", result)
91+
assert.Equal(t, 5, calls)
92+
}
93+
94+
func TestDoWithRetry_ContextCanceledDuringBackoff(t *testing.T) {
95+
ctx, cancel := context.WithCancel(context.Background())
96+
calls := 0
97+
done := make(chan struct{})
98+
var result string
99+
var err error
100+
go func() {
101+
result, err = DoWithRetry(ctx, func() (string, *core.DetailedResponse, error) {
102+
calls++
103+
return "", &core.DetailedResponse{StatusCode: 429}, nil
104+
})
105+
close(done)
106+
}()
107+
cancel()
108+
<-done
109+
require.Error(t, err)
110+
assert.ErrorIs(t, err, context.Canceled)
111+
assert.Equal(t, "", result)
112+
assert.Equal(t, 1, calls)
113+
}
114+
115+
func TestDoWithRetry_RespectsRetryAfterHeader(t *testing.T) {
116+
ctx := context.Background()
117+
calls := 0
118+
headers := http.Header{}
119+
headers.Set("Retry-After", "1")
120+
result, err := DoWithRetry(ctx, func() (string, *core.DetailedResponse, error) {
121+
calls++
122+
if calls == 1 {
123+
return "", &core.DetailedResponse{StatusCode: 429, Headers: headers}, nil
124+
}
125+
return "ok", &core.DetailedResponse{StatusCode: 200}, nil
126+
})
127+
require.NoError(t, err)
128+
assert.Equal(t, "ok", result)
129+
assert.Equal(t, 2, calls)
130+
}

0 commit comments

Comments
 (0)