Skip to content

Commit f20a101

Browse files
authored
feat: add retry option to graphql client (#168)
1 parent 3589de2 commit f20a101

File tree

2 files changed

+208
-2
lines changed

2 files changed

+208
-2
lines changed

graphql.go

Lines changed: 62 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,13 @@ import (
55
"compress/gzip"
66
"context"
77
"encoding/json"
8+
"errors"
89
"fmt"
910
"io"
1011
"net/http"
12+
"net/url"
1113
"strings"
14+
"time"
1215

1316
"github.com/hasura/go-graphql-client/pkg/jsonutil"
1417
)
@@ -19,6 +22,15 @@ type Doer interface {
1922
Do(*http.Request) (*http.Response, error)
2023
}
2124

25+
// ClientOption is used to configure client with options
26+
type ClientOption func(c *Client)
27+
28+
func WithRetry(retries int) ClientOption {
29+
return func(c *Client) {
30+
c.retries = retries
31+
}
32+
}
33+
2234
// This function allows you to tweak the HTTP request. It might be useful to set authentication
2335
// headers amongst other things
2436
type RequestModifier func(*http.Request)
@@ -29,19 +41,27 @@ type Client struct {
2941
httpClient Doer
3042
requestModifier RequestModifier
3143
debug bool
44+
retries int // max number of retries, defaults to 0 for no retry see WithRetry option
3245
}
3346

3447
// NewClient creates a GraphQL client targeting the specified GraphQL server URL.
3548
// If httpClient is nil, then http.DefaultClient is used.
36-
func NewClient(url string, httpClient Doer) *Client {
49+
func NewClient(url string, httpClient Doer, options ...ClientOption) *Client {
3750
if httpClient == nil {
3851
httpClient = http.DefaultClient
3952
}
40-
return &Client{
53+
54+
c := &Client{
4155
url: url,
4256
httpClient: httpClient,
4357
requestModifier: nil,
4458
}
59+
60+
for _, opt := range options {
61+
opt(c)
62+
}
63+
64+
return c
4565
}
4666

4767
// Query executes a single GraphQL query request,
@@ -122,6 +142,37 @@ func (c *Client) buildQueryAndOptions(op operationType, v any, variables map[str
122142

123143
// Request the common method that send graphql request
124144
func (c *Client) request(ctx context.Context, query string, variables map[string]any, options *constructOptionsOutput) ([]byte, []byte, *http.Response, io.Reader, Errors) {
145+
var (
146+
rawData, extensions []byte
147+
resp *http.Response
148+
respReader io.Reader
149+
err Errors
150+
)
151+
c.withRetry(func() error {
152+
rawData, extensions, resp, respReader, err = c.doRequest(ctx, query, variables, options)
153+
return err
154+
})
155+
return rawData, extensions, resp, respReader, err
156+
}
157+
158+
func (c *Client) withRetry(exec func() error) {
159+
maxAttempts := c.retries + 1
160+
for attempt := 1; attempt <= maxAttempts; attempt++ {
161+
err := exec()
162+
if err == nil {
163+
return
164+
}
165+
166+
if attempt == maxAttempts || !c.shouldRetry(err) {
167+
return
168+
}
169+
170+
time.Sleep(time.Duration(attempt) * time.Second)
171+
}
172+
}
173+
174+
// doRequest sends graphql request.
175+
func (c *Client) doRequest(ctx context.Context, query string, variables map[string]any, options *constructOptionsOutput) ([]byte, []byte, *http.Response, io.Reader, Errors) {
125176
in := GraphQLRequestPayload{
126177
Query: query,
127178
Variables: variables,
@@ -256,6 +307,15 @@ func (c *Client) request(ctx context.Context, query string, variables map[string
256307
return rawData, extensions, resp, respReader, nil
257308
}
258309

310+
// shouldRetry determines whether a request should retry or not.
311+
func (c *Client) shouldRetry(err error) bool {
312+
var uErr *url.Error
313+
if errors.As(err, &uErr) {
314+
return uErr.Timeout() || uErr.Temporary()
315+
}
316+
return true
317+
}
318+
259319
// do executes a single GraphQL operation.
260320
// return raw message and error
261321
func (c *Client) doRaw(ctx context.Context, op operationType, v any, variables map[string]any, options ...Option) ([]byte, error) {

graphql_test.go

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,11 @@ import (
55
"encoding/json"
66
"errors"
77
"io"
8+
"net"
89
"net/http"
910
"net/http/httptest"
1011
"testing"
12+
"time"
1113

1214
"github.com/hasura/go-graphql-client"
1315
)
@@ -619,3 +621,147 @@ func mustWrite(w io.Writer, s string) {
619621
panic(err)
620622
}
621623
}
624+
625+
func TestClientOption_WithRetry_succeed(t *testing.T) {
626+
var (
627+
attempts int
628+
maxAttempts = 3
629+
)
630+
mux := http.NewServeMux()
631+
mux.HandleFunc("/graphql", func(w http.ResponseWriter, req *http.Request) {
632+
attempts++
633+
// Simulate a temporary network error except for the last attempt
634+
if attempts <= maxAttempts-1 {
635+
http.Error(w, "temporary error", http.StatusServiceUnavailable)
636+
return
637+
}
638+
// Succeed on the last attempt
639+
w.Header().Set("Content-Type", "application/json")
640+
mustWrite(w, `{"data": {"user": {"name": "Gopher"}}}`)
641+
})
642+
643+
client := graphql.NewClient("/graphql",
644+
&http.Client{
645+
Transport: localRoundTripper{
646+
handler: mux,
647+
},
648+
},
649+
graphql.WithRetry(maxAttempts-1),
650+
)
651+
652+
var q struct {
653+
User struct {
654+
Name string
655+
}
656+
}
657+
658+
err := client.Query(context.Background(), &q, nil)
659+
if err != nil {
660+
t.Fatalf("got error: %v, want nil", err)
661+
}
662+
663+
if attempts != maxAttempts {
664+
t.Errorf("got %d attempts, want %d", attempts, maxAttempts)
665+
}
666+
667+
if q.User.Name != "Gopher" {
668+
t.Errorf("got q.User.Name: %q, want Gopher", q.User.Name)
669+
}
670+
}
671+
672+
func TestClientOption_WithRetry_maxRetriesExceeded(t *testing.T) {
673+
var (
674+
attempts int
675+
maxAttempts = 2
676+
)
677+
mux := http.NewServeMux()
678+
mux.HandleFunc("/graphql", func(w http.ResponseWriter, req *http.Request) {
679+
attempts++
680+
// Always fail with a temporary error
681+
http.Error(w, "temporary error", http.StatusServiceUnavailable)
682+
})
683+
684+
client := graphql.NewClient("/graphql",
685+
&http.Client{
686+
Transport: localRoundTripper{
687+
handler: mux,
688+
},
689+
},
690+
graphql.WithRetry(maxAttempts-1),
691+
)
692+
693+
var q struct {
694+
User struct {
695+
Name string
696+
}
697+
}
698+
699+
err := client.Query(context.Background(), &q, nil)
700+
if err == nil {
701+
t.Fatal("got nil, want error")
702+
}
703+
704+
// Check that we got the max retries exceeded error
705+
var gqlErrs graphql.Errors
706+
if !errors.As(err, &gqlErrs) {
707+
t.Fatalf("got %T, want graphql.Errors", err)
708+
}
709+
710+
if len(gqlErrs) != 1 {
711+
t.Fatalf("got %d, want 1 error", len(gqlErrs))
712+
}
713+
714+
// First request does not count
715+
if attempts != maxAttempts {
716+
t.Errorf("got %d attempts, want %d", attempts, maxAttempts)
717+
}
718+
}
719+
720+
// Define the custom RoundTripper type
721+
type roundTripperWithRetryCount struct {
722+
Transport *http.Transport
723+
attempts *int
724+
}
725+
726+
// Define RoundTrip method for the type
727+
func (c roundTripperWithRetryCount) RoundTrip(req *http.Request) (*http.Response, error) {
728+
if c.attempts != nil {
729+
*c.attempts++
730+
}
731+
return c.Transport.RoundTrip(req)
732+
}
733+
734+
func TestClientOption_WithRetry_shouldNotRetry(t *testing.T) {
735+
var attempts int
736+
737+
client := graphql.NewClient("/graphql",
738+
&http.Client{
739+
Transport: roundTripperWithRetryCount{
740+
attempts: &attempts,
741+
Transport: &http.Transport{
742+
DialContext: (&net.Dialer{
743+
Timeout: 3 * time.Second,
744+
KeepAlive: 3 * time.Second,
745+
}).DialContext,
746+
},
747+
},
748+
},
749+
graphql.WithRetry(3),
750+
)
751+
752+
var q struct {
753+
User struct {
754+
Name string
755+
}
756+
}
757+
758+
err := client.Query(context.Background(), &q, nil)
759+
if err == nil {
760+
t.Fatal("got nil, want error")
761+
}
762+
763+
// Should not retry on permanent URL errors
764+
if attempts != 1 {
765+
t.Errorf("got %d attempts, want 1", attempts)
766+
}
767+
}

0 commit comments

Comments
 (0)