Skip to content

Commit e5bdbb3

Browse files
add options struct,
1 parent 50098a7 commit e5bdbb3

File tree

6 files changed

+305
-48
lines changed

6 files changed

+305
-48
lines changed

products/compute/client.go

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

shared/configuration.go

Lines changed: 19 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,24 @@ const (
137137
FailoverRoundRobin FailoverStrategy = "roundRobin"
138138
)
139139

140+
// FailoverOptions controls transport-level endpoint failover behaviour.
141+
// It is nested under Configuration so it can be grouped cleanly in JSON/YAML.
142+
type FailoverOptions struct {
143+
Strategy FailoverStrategy `json:"strategy,omitempty" yaml:"strategy,omitempty"`
144+
145+
// RetryableMethods controls which HTTP methods are eligible for transport-level
146+
// failover retries. If empty/nil, the default is safe/idempotent methods.
147+
RetryableMethods []string `json:"retryableMethods,omitempty" yaml:"retryableMethods,omitempty"`
148+
149+
// RetryOnTimeout controls whether failover retries should also happen when
150+
// the request fails due to context cancellation/deadline exceeded.
151+
RetryOnTimeout bool `json:"retryOnTimeout,omitempty" yaml:"retryOnTimeout,omitempty"`
152+
153+
// FailoverOnStatusCodes controls whether the transport should fail over to the
154+
// next server when it receives one of these HTTP status codes.
155+
FailoverOnStatusCodes []int `json:"failoverOnStatusCodes,omitempty" yaml:"failoverOnStatusCodes,omitempty"`
156+
}
157+
140158
// Configuration stores the configuration of the API client
141159
type Configuration struct {
142160
Host string `json:"host,omitempty"`
@@ -159,18 +177,7 @@ type Configuration struct {
159177
MiddlewareWithError MiddlewareFunctionWithError `json:"-"`
160178
ResponseMiddleware ResponseMiddlewareFunction `json:"-"`
161179

162-
FailoverStrategy FailoverStrategy `json:"failoverStrategy,omitempty"`
163-
164-
// RetryableMethods controls which HTTP methods are eligible for transport-level
165-
// failover retries.
166-
// If empty/nil, the default is to retry only safe/idempotent methods: GET, HEAD,
167-
// PUT, DELETE, OPTIONS.
168-
RetryableMethods []string `json:"retryableMethods,omitempty"`
169-
170-
// RetryOnTimeout controls whether transport-level failover retries should also
171-
// happen when the request fails due to context cancellation/deadline exceeded.
172-
// Default is false to avoid duplicate side effects for non-idempotent requests.
173-
RetryOnTimeout bool `json:"retryOnTimeout,omitempty"`
180+
Failover *FailoverOptions `json:"failover,omitempty"`
174181
}
175182

176183
// NewConfiguration returns a new shared.Configuration object.

shared/failover_roundtripper.go

Lines changed: 87 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ package shared
77
import (
88
"context"
99
"errors"
10+
"fmt"
11+
"io"
1012
"net"
1113
"net/http"
1214
"net/url"
@@ -32,16 +34,19 @@ import (
3234
// preserving path and query.
3335
type FailoverRoundTripper struct {
3436
cfg *Configuration
37+
opts *FailoverOptions
3538
base http.RoundTripper
3639
}
3740

38-
// NewFailoverRoundTripper creates a new FailoverRoundTripper with the given configuration and base RoundTripper.
39-
func NewFailoverRoundTripper(cfg *Configuration, base http.RoundTripper) http.RoundTripper {
41+
// NewFailoverRoundTripper creates a new FailoverRoundTripper.
42+
// If opts is nil, it will fall back to cfg.Failover.
43+
func NewFailoverRoundTripper(cfg *Configuration, opts *FailoverOptions, base http.RoundTripper) http.RoundTripper {
4044
if base == nil {
4145
base = http.DefaultTransport
4246
}
4347
return &FailoverRoundTripper{
4448
cfg: cfg,
49+
opts: opts,
4550
base: base,
4651
}
4752
}
@@ -51,60 +56,86 @@ func (t *FailoverRoundTripper) RoundTrip(req *http.Request) (*http.Response, err
5156
if req == nil {
5257
return nil, errors.New("nil request")
5358
}
54-
if t == nil || t.cfg == nil {
55-
if t != nil && t.base != nil {
56-
return t.base.RoundTrip(req)
57-
}
58-
return http.DefaultTransport.RoundTrip(req)
59+
if t == nil {
60+
return nil, errors.New("nil FailoverRoundTripper")
61+
}
62+
if t.base == nil {
63+
// Be resilient if instantiated without constructor.
64+
t.base = http.DefaultTransport
65+
}
66+
if t.cfg == nil {
67+
// No config => behave like the base transport.
68+
return t.base.RoundTrip(req)
5969
}
6070

61-
strategyName := strings.TrimSpace(strings.ToLower(string(t.cfg.FailoverStrategy)))
62-
servers := len(t.cfg.Servers)
63-
if strategyName == "" || strategyName == string(FailoverNone) || servers <= 1 {
71+
fo := t.opts
72+
if fo == nil {
73+
fo = t.cfg.Failover
74+
}
75+
if fo == nil {
6476
return t.base.RoundTrip(req)
6577
}
6678

67-
if strategyName != strings.ToLower(string(FailoverRoundRobin)) {
79+
servers := t.cfg.Servers
80+
order := serverOrderFor(fo.Strategy, len(servers))
81+
if order == nil {
82+
// Unknown or disabled strategy => pass through.
6883
return t.base.RoundTrip(req)
6984
}
7085

7186
// Check if method is allowed for failover retries.
72-
if !isRetryableMethod(t.cfg, req.Method) {
87+
if !isRetryableMethod(fo, req.Method) {
7388
return t.base.RoundTrip(req)
7489
}
7590

7691
var lastErr error
77-
for i := range servers {
78-
// Always start from the first server in the list.
79-
serverURL := t.cfg.Servers[i].URL
92+
for attempt := range len(servers) {
93+
serverURL := servers[order(attempt)].URL
8094

8195
targetURL, err := url.Parse(serverURL)
8296
if err != nil {
83-
lastErr = err
84-
continue
97+
return nil, fmt.Errorf("invalid server URL at Servers[%d]=%q: %w", order(attempt), serverURL, err)
8598
}
8699

87100
attemptReq, err := cloneRequestForRetry(req)
88101
if err != nil {
89102
return nil, err
90103
}
91104

92-
// Update both URL and the Host header field.
93105
attemptReq.URL.Scheme = targetURL.Scheme
94106
attemptReq.URL.Host = targetURL.Host
95107
attemptReq.Host = targetURL.Host
96108

109+
if SdkLogLevel.Satisfies(Debug) {
110+
SdkLogger.Printf("[Failover] attempt=%d method=%s url=%s", attempt+1, attemptReq.Method, attemptReq.URL.String())
111+
}
112+
97113
resp, err := t.base.RoundTrip(attemptReq)
98114
if err == nil {
115+
if shouldFailoverOnStatus(fo, resp.StatusCode) {
116+
if SdkLogLevel.Satisfies(Debug) {
117+
SdkLogger.Printf("[Failover] status=%d triggers failover to next server", resp.StatusCode)
118+
}
119+
// Drain/close body to allow connection reuse.
120+
if resp.Body != nil {
121+
_, _ = io.Copy(io.Discard, resp.Body)
122+
_ = resp.Body.Close()
123+
}
124+
lastErr = fmt.Errorf("failover status: %s", resp.Status)
125+
continue
126+
}
99127
return resp, nil
100128
}
101129

102130
lastErr = err
103-
if !isNetworkErrorRT(attemptReq.Context(), err, t.cfg.RetryOnTimeout) {
131+
retryable := isNetworkErrorRT(attemptReq.Context(), err, fo.RetryOnTimeout)
132+
if !retryable {
104133
return nil, err
105134
}
135+
if SdkLogLevel.Satisfies(Debug) {
136+
SdkLogger.Printf("[Failover] network error: %v; trying next server", err)
137+
}
106138

107-
// Ensure we don't spin too hot in case of immediate failures.
108139
tinyBackoff(attemptReq.Context())
109140
}
110141

@@ -126,18 +157,17 @@ func cloneRequestForRetry(req *http.Request) (*http.Request, error) {
126157
return clone, nil
127158
}
128159

129-
func isRetryableMethod(cfg *Configuration, method string) bool {
160+
func isRetryableMethod(fo *FailoverOptions, method string) bool {
130161
m := strings.ToUpper(strings.TrimSpace(method))
131-
if cfg == nil {
162+
if fo == nil {
132163
return defaultRetryableMethods[m]
133164
}
134165

135-
// If not configured, use defaults.
136-
if len(cfg.RetryableMethods) == 0 {
166+
if len(fo.RetryableMethods) == 0 {
137167
return defaultRetryableMethods[m]
138168
}
139169

140-
for _, v := range cfg.RetryableMethods {
170+
for _, v := range fo.RetryableMethods {
141171
if strings.ToUpper(strings.TrimSpace(v)) == m {
142172
return true
143173
}
@@ -208,3 +238,35 @@ func tinyBackoff(ctx context.Context) {
208238
case <-ctx.Done():
209239
}
210240
}
241+
242+
func shouldFailoverOnStatus(fo *FailoverOptions, statusCode int) bool {
243+
if fo == nil || len(fo.FailoverOnStatusCodes) == 0 {
244+
return false
245+
}
246+
for _, sc := range fo.FailoverOnStatusCodes {
247+
if sc == statusCode {
248+
return true
249+
}
250+
}
251+
return false
252+
}
253+
254+
// serverOrder maps an attempt index (0, 1, 2, …) to a server index.
255+
// Different strategies produce different orderings.
256+
type serverOrder func(attempt int) int
257+
258+
// serverOrderFor returns a serverOrder for the given strategy, or nil when
259+
// failover should not be applied (unknown/disabled strategy, ≤1 server).
260+
func serverOrderFor(strategy FailoverStrategy, numServers int) serverOrder {
261+
s := strings.TrimSpace(strings.ToLower(string(strategy)))
262+
if s == "" || s == string(FailoverNone) || numServers <= 1 {
263+
return nil
264+
}
265+
switch s {
266+
case strings.ToLower(string(FailoverRoundRobin)):
267+
// Sequential: 0, 1, 2, …
268+
return func(attempt int) int { return attempt % numServers }
269+
default:
270+
return nil
271+
}
272+
}

shared/failover_roundtripper_test.go

Lines changed: 53 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -38,17 +38,18 @@ func (f *fakeTransport) RoundTrip(r *http.Request) (*http.Response, error) {
3838

3939
func TestFailoverRoundTripper_RoundRobin_NetworkError_FailsOverToNextServer(t *testing.T) {
4040
cfg := &Configuration{
41-
FailoverStrategy: FailoverRoundRobin,
41+
Failover: &FailoverOptions{
42+
Strategy: FailoverRoundRobin,
43+
RetryOnTimeout: false,
44+
},
4245
Servers: ServerConfigurations{
4346
{URL: "https://s1.example"},
4447
{URL: "https://s2.example"},
4548
},
46-
// default RetryableMethods covers GET
47-
RetryOnTimeout: false,
4849
}
4950

5051
ft := &fakeTransport{}
51-
rt := NewFailoverRoundTripper(cfg, ft)
52+
rt := NewFailoverRoundTripper(cfg, cfg.Failover, ft)
5253

5354
req, err := http.NewRequest(http.MethodGet, "https://s1.example/some/path?x=1", nil)
5455
if err != nil {
@@ -76,16 +77,18 @@ func TestFailoverRoundTripper_RoundRobin_NetworkError_FailsOverToNextServer(t *t
7677

7778
func TestFailoverRoundTripper_DoesNotRetry_WhenMethodNotRetryable(t *testing.T) {
7879
cfg := &Configuration{
79-
FailoverStrategy: FailoverRoundRobin,
80+
Failover: &FailoverOptions{
81+
Strategy: FailoverRoundRobin,
82+
RetryableMethods: []string{http.MethodGet},
83+
},
8084
Servers: ServerConfigurations{
8185
{URL: "https://s1.example"},
8286
{URL: "https://s2.example"},
8387
},
84-
RetryableMethods: []string{http.MethodGet},
8588
}
8689

8790
ft := &fakeTransport{}
88-
rt := NewFailoverRoundTripper(cfg, ft)
91+
rt := NewFailoverRoundTripper(cfg, cfg.Failover, ft)
8992

9093
// POST is not retryable per config
9194
req, err := http.NewRequest(http.MethodPost, "https://s1.example/some/path", io.NopCloser(bytes.NewBufferString("x")))
@@ -112,12 +115,14 @@ func TestFailoverRoundTripper_DoesNotRetry_WhenMethodNotRetryable(t *testing.T)
112115

113116
func TestFailoverRoundTripper_RetriesOnNoSuchHost(t *testing.T) {
114117
cfg := &Configuration{
115-
FailoverStrategy: FailoverRoundRobin,
118+
Failover: &FailoverOptions{
119+
Strategy: FailoverRoundRobin,
120+
RetryOnTimeout: false,
121+
},
116122
Servers: ServerConfigurations{
117123
{URL: "https://s1.example"},
118124
{URL: "https://s2.example"},
119125
},
120-
RetryOnTimeout: false,
121126
}
122127

123128
ft := &fakeTransport{}
@@ -126,7 +131,7 @@ func TestFailoverRoundTripper_RetriesOnNoSuchHost(t *testing.T) {
126131
_ = ft2
127132

128133
// Inline transport to simulate DNS error.
129-
rt := NewFailoverRoundTripper(cfg, roundTripperFunc(func(r *http.Request) (*http.Response, error) {
134+
rt := NewFailoverRoundTripper(cfg, cfg.Failover, roundTripperFunc(func(r *http.Request) (*http.Response, error) {
130135
host := ""
131136
urlStr := ""
132137
if r != nil && r.URL != nil {
@@ -160,6 +165,44 @@ func TestFailoverRoundTripper_RetriesOnNoSuchHost(t *testing.T) {
160165
}
161166
}
162167

168+
func TestFailoverRoundTripper_FailoverOnStatusCodes(t *testing.T) {
169+
cfg := &Configuration{
170+
Failover: &FailoverOptions{
171+
Strategy: FailoverRoundRobin,
172+
FailoverOnStatusCodes: []int{http.StatusServiceUnavailable},
173+
},
174+
Servers: ServerConfigurations{
175+
{URL: "https://s1.example"},
176+
{URL: "https://s2.example"},
177+
},
178+
}
179+
180+
calls := []string{}
181+
rt := NewFailoverRoundTripper(cfg, cfg.Failover, roundTripperFunc(func(r *http.Request) (*http.Response, error) {
182+
calls = append(calls, r.URL.Host)
183+
if r.URL.Host == "s1.example" {
184+
return &http.Response{Status: "503 Service Unavailable", StatusCode: http.StatusServiceUnavailable, Body: io.NopCloser(bytes.NewBufferString("no")), Header: make(http.Header), Request: r}, nil
185+
}
186+
return &http.Response{Status: "200 OK", StatusCode: http.StatusOK, Body: io.NopCloser(bytes.NewBufferString("ok")), Header: make(http.Header), Request: r}, nil
187+
}))
188+
189+
req, err := http.NewRequest(http.MethodGet, "https://s1.example/some/path", nil)
190+
if err != nil {
191+
t.Fatalf("unexpected error creating request: %v", err)
192+
}
193+
194+
resp, err := rt.RoundTrip(req)
195+
if err != nil {
196+
t.Fatalf("expected success, got error: %v", err)
197+
}
198+
if resp.StatusCode != http.StatusOK {
199+
t.Fatalf("expected 200, got %d", resp.StatusCode)
200+
}
201+
if len(calls) != 2 || calls[0] != "s1.example" || calls[1] != "s2.example" {
202+
t.Fatalf("unexpected call order: %+v", calls)
203+
}
204+
}
205+
163206
type roundTripperFunc func(*http.Request) (*http.Response, error)
164207

165208
func (f roundTripperFunc) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) }

0 commit comments

Comments
 (0)