Skip to content

Commit 34b7f00

Browse files
authored
Make retrying transport and http errors configurable (#1122)
* Only wrap transport if it is a transport.Transport Note: This is a breaking change Authored-by: Dennis Leon <leonde@vmware.com> * Provide additional information in transport.Error - Useful by consumers providing their own Predicate to determine whether to retry or not Authored-by: Dennis Leon <leonde@vmware.com> * Add options to configure predicate/backoff when handling higher level http retries Authored-by: Dennis Leon <leonde@vmware.com> * backfill test - add test to assert behavior around using a transport.Wrapper results in no additional wrapping such as retry is done. refactoring - add comments - rename transport.Transport -> transport.Wrapper - make transport package return transport.Wrapper Authored-by: Dennis Leon <leonde@vmware.com> * Stop exposing Inner from transport.Wrapper - Consumers should construct a transport.Wrapper via constructor transport.NewWithContext - options retryBackoff and retryPredicate should only apply to http errors and not lower level transport errors. (Consumers can still provide a transport with the retry behavior they want) Authored-by: Dennis Leon <leonde@vmware.com>
1 parent c71ca9b commit 34b7f00

File tree

10 files changed

+233
-77
lines changed

10 files changed

+233
-77
lines changed

pkg/v1/google/list.go

Lines changed: 15 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -57,19 +57,23 @@ func newLister(repo name.Repository, options ...Option) (*lister, error) {
5757
}
5858
}
5959

60-
// Wrap the transport in something that logs requests and responses.
61-
// It's expensive to generate the dumps, so skip it if we're writing
62-
// to nothing.
63-
if logs.Enabled(logs.Debug) {
64-
l.transport = transport.NewLogger(l.transport)
65-
}
60+
// transport.Wrapper is a signal that consumers are opt-ing into providing their own transport without any additional wrapping.
61+
// This is to allow consumers full control over the transports logic, such as providing retry logic.
62+
if _, ok := l.transport.(*transport.Wrapper); !ok {
63+
// Wrap the transport in something that logs requests and responses.
64+
// It's expensive to generate the dumps, so skip it if we're writing
65+
// to nothing.
66+
if logs.Enabled(logs.Debug) {
67+
l.transport = transport.NewLogger(l.transport)
68+
}
6669

67-
// Wrap the transport in something that can retry network flakes.
68-
l.transport = transport.NewRetry(l.transport)
70+
// Wrap the transport in something that can retry network flakes.
71+
l.transport = transport.NewRetry(l.transport)
6972

70-
// Wrap this last to prevent transport.New from double-wrapping.
71-
if l.userAgent != "" {
72-
l.transport = transport.NewUserAgent(l.transport, l.userAgent)
73+
// Wrap this last to prevent transport.New from double-wrapping.
74+
if l.userAgent != "" {
75+
l.transport = transport.NewUserAgent(l.transport, l.userAgent)
76+
}
7377
}
7478

7579
scopes := []string{repo.Scope(transport.PullScope)}

pkg/v1/remote/multi_write.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,8 @@ func MultiWrite(m map[name.Reference]Taggable, options ...Option) (rerr error) {
9292
context: o.context,
9393
updates: o.updates,
9494
lastUpdate: &v1.Update{},
95+
backoff: o.retryBackoff,
96+
predicate: o.retryPredicate,
9597
}
9698

9799
// Collect the total size of blobs and manifests we're about to write.

pkg/v1/remote/multi_write_test.go

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@
1515
package remote
1616

1717
import (
18+
"context"
19+
"io"
1820
"io/ioutil"
1921
"log"
2022
"net/http"
@@ -28,6 +30,7 @@ import (
2830
"github.com/google/go-containerregistry/pkg/v1/empty"
2931
"github.com/google/go-containerregistry/pkg/v1/mutate"
3032
"github.com/google/go-containerregistry/pkg/v1/random"
33+
"github.com/google/go-containerregistry/pkg/v1/remote/transport"
3134
"github.com/google/go-containerregistry/pkg/v1/types"
3235
"github.com/google/go-containerregistry/pkg/v1/validate"
3336
)
@@ -220,6 +223,78 @@ func TestMultiWrite_Retry(t *testing.T) {
220223
}
221224

222225
})
226+
227+
t.Run("do not retry transport errors if transport.Wrapper is used", func(t *testing.T) {
228+
// reference a http server that is not listening (used to pick a port that isn't listening)
229+
onlyHandlesPing := http.HandlerFunc(func(responseWriter http.ResponseWriter, request *http.Request) {
230+
if strings.HasSuffix(request.URL.Path, "/v2/") {
231+
responseWriter.WriteHeader(200)
232+
return
233+
}
234+
})
235+
s := httptest.NewServer(onlyHandlesPing)
236+
defer s.Close()
237+
238+
u, err := url.Parse(s.URL)
239+
if err != nil {
240+
t.Fatal(err)
241+
}
242+
243+
tag1 := mustNewTag(t, u.Host+"/repo:tag1")
244+
245+
// using a transport.Wrapper, meaning retry logic should not be wrapped
246+
doesNotRetryTransport := &countTransport{inner: http.DefaultTransport}
247+
transportWrapper, err := transport.NewWithContext(context.Background(), tag1.Repository.Registry, nil, doesNotRetryTransport, nil)
248+
if err != nil {
249+
t.Fatal(err)
250+
}
251+
252+
if err := MultiWrite(map[name.Reference]Taggable{
253+
tag1: img1,
254+
}, WithTransport(transportWrapper), WithJobs(1)); err == nil {
255+
t.Errorf("Expected an error, got nil")
256+
}
257+
258+
// expect count == 1 since jobs is set to 1 and we should not retry on transport eof error
259+
if doesNotRetryTransport.count != 1 {
260+
t.Errorf("Incorrect count, got %d, want %d", doesNotRetryTransport.count, 1)
261+
}
262+
})
263+
264+
t.Run("do not add UserAgent if transport.Wrapper is used", func(t *testing.T) {
265+
expectedNotUsedUserAgent := "TEST_USER_AGENT"
266+
267+
handler := registry.New()
268+
269+
registryThatAssertsUserAgentIsCorrect := http.HandlerFunc(func(responseWriter http.ResponseWriter, request *http.Request) {
270+
if strings.Contains(request.Header.Get("User-Agent"), expectedNotUsedUserAgent) {
271+
t.Fatalf("Should not contain User-Agent: %s, Got: %s", expectedNotUsedUserAgent, request.Header.Get("User-Agent"))
272+
}
273+
274+
handler.ServeHTTP(responseWriter, request)
275+
})
276+
277+
s := httptest.NewServer(registryThatAssertsUserAgentIsCorrect)
278+
279+
defer s.Close()
280+
u, err := url.Parse(s.URL)
281+
if err != nil {
282+
t.Fatal(err)
283+
}
284+
285+
tag1 := mustNewTag(t, u.Host+"/repo:tag1")
286+
// using a transport.Wrapper, meaning retry logic should not be wrapped
287+
transportWrapper, err := transport.NewWithContext(context.Background(), tag1.Repository.Registry, nil, http.DefaultTransport, nil)
288+
if err != nil {
289+
t.Fatal(err)
290+
}
291+
292+
if err := MultiWrite(map[name.Reference]Taggable{
293+
tag1: img1,
294+
}, WithTransport(transportWrapper), WithUserAgent(expectedNotUsedUserAgent)); err != nil {
295+
t.Fatal(err)
296+
}
297+
})
223298
}
224299

225300
// TestMultiWrite_Deep tests that a deeply nested tree of manifest lists gets
@@ -259,3 +334,17 @@ func TestMultiWrite_Deep(t *testing.T) {
259334
t.Error("Validate() =", err)
260335
}
261336
}
337+
338+
type countTransport struct {
339+
count int
340+
inner http.RoundTripper
341+
}
342+
343+
func (t *countTransport) RoundTrip(req *http.Request) (*http.Response, error) {
344+
if strings.HasSuffix(req.URL.Path, "/v2/") {
345+
return t.inner.RoundTrip(req)
346+
}
347+
348+
t.count++
349+
return nil, io.ErrUnexpectedEOF
350+
}

pkg/v1/remote/options.go

Lines changed: 68 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,12 @@ package remote
1717
import (
1818
"context"
1919
"errors"
20+
"io"
2021
"net/http"
22+
"syscall"
23+
"time"
2124

25+
"github.com/google/go-containerregistry/internal/retry"
2226
"github.com/google/go-containerregistry/pkg/authn"
2327
"github.com/google/go-containerregistry/pkg/logs"
2428
v1 "github.com/google/go-containerregistry/pkg/v1"
@@ -39,13 +43,36 @@ type options struct {
3943
allowNondistributableArtifacts bool
4044
updates chan<- v1.Update
4145
pageSize int
46+
retryBackoff Backoff
47+
retryPredicate retry.Predicate
4248
}
4349

4450
var defaultPlatform = v1.Platform{
4551
Architecture: "amd64",
4652
OS: "linux",
4753
}
4854

55+
// Backoff is an alias of retry.Backoff to expose this configuration option to consumers of this lib
56+
type Backoff = retry.Backoff
57+
58+
var defaultRetryPredicate retry.Predicate = func(err error) bool {
59+
// Various failure modes here, as we're often reading from and writing to
60+
// the network.
61+
if retry.IsTemporary(err) || errors.Is(err, io.ErrUnexpectedEOF) || errors.Is(err, syscall.EPIPE) {
62+
logs.Warn.Printf("retrying %v", err)
63+
return true
64+
}
65+
return false
66+
}
67+
68+
// Try this three times, waiting 1s after first failure, 3s after second.
69+
var defaultRetryBackoff = Backoff{
70+
Duration: 1.0 * time.Second,
71+
Factor: 3.0,
72+
Jitter: 0.1,
73+
Steps: 3,
74+
}
75+
4976
const (
5077
defaultJobs = 4
5178

@@ -56,12 +83,14 @@ const (
5683

5784
func makeOptions(target authn.Resource, opts ...Option) (*options, error) {
5885
o := &options{
59-
auth: authn.Anonymous,
60-
transport: http.DefaultTransport,
61-
platform: defaultPlatform,
62-
context: context.Background(),
63-
jobs: defaultJobs,
64-
pageSize: defaultPageSize,
86+
auth: authn.Anonymous,
87+
transport: http.DefaultTransport,
88+
platform: defaultPlatform,
89+
context: context.Background(),
90+
jobs: defaultJobs,
91+
pageSize: defaultPageSize,
92+
retryPredicate: defaultRetryPredicate,
93+
retryBackoff: defaultRetryBackoff,
6594
}
6695

6796
for _, option := range opts {
@@ -78,26 +107,32 @@ func makeOptions(target authn.Resource, opts ...Option) (*options, error) {
78107
o.auth = auth
79108
}
80109

81-
// Wrap the transport in something that logs requests and responses.
82-
// It's expensive to generate the dumps, so skip it if we're writing
83-
// to nothing.
84-
if logs.Enabled(logs.Debug) {
85-
o.transport = transport.NewLogger(o.transport)
86-
}
110+
// transport.Wrapper is a signal that consumers are opt-ing into providing their own transport without any additional wrapping.
111+
// This is to allow consumers full control over the transports logic, such as providing retry logic.
112+
if _, ok := o.transport.(*transport.Wrapper); !ok {
113+
// Wrap the transport in something that logs requests and responses.
114+
// It's expensive to generate the dumps, so skip it if we're writing
115+
// to nothing.
116+
if logs.Enabled(logs.Debug) {
117+
o.transport = transport.NewLogger(o.transport)
118+
}
87119

88-
// Wrap the transport in something that can retry network flakes.
89-
o.transport = transport.NewRetry(o.transport)
120+
// Wrap the transport in something that can retry network flakes.
121+
o.transport = transport.NewRetry(o.transport)
90122

91-
// Wrap this last to prevent transport.New from double-wrapping.
92-
if o.userAgent != "" {
93-
o.transport = transport.NewUserAgent(o.transport, o.userAgent)
123+
// Wrap this last to prevent transport.New from double-wrapping.
124+
if o.userAgent != "" {
125+
o.transport = transport.NewUserAgent(o.transport, o.userAgent)
126+
}
94127
}
95128

96129
return o, nil
97130
}
98131

99132
// WithTransport is a functional option for overriding the default transport
100133
// for remote operations.
134+
// If transport.Wrapper is provided, this signals that the consumer does *not* want any further wrapping to occur.
135+
// i.e. logging, retry and useragent
101136
//
102137
// The default transport its http.DefaultTransport.
103138
func WithTransport(t http.RoundTripper) Option {
@@ -212,3 +247,19 @@ func WithPageSize(size int) Option {
212247
return nil
213248
}
214249
}
250+
251+
// WithRetryBackoff sets the httpBackoff for retry HTTP operations.
252+
func WithRetryBackoff(backoff Backoff) Option {
253+
return func(o *options) error {
254+
o.retryBackoff = backoff
255+
return nil
256+
}
257+
}
258+
259+
// WithRetryPredicate sets the predicate for retry HTTP operations.
260+
func WithRetryPredicate(predicate retry.Predicate) Option {
261+
return func(o *options) error {
262+
o.retryPredicate = predicate
263+
return nil
264+
}
265+
}

pkg/v1/remote/transport/error.go

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -46,10 +46,10 @@ type Error struct {
4646
Errors []Diagnostic `json:"errors,omitempty"`
4747
// The http status code returned.
4848
StatusCode int
49+
// The request that failed.
50+
Request *http.Request
4951
// The raw body if we couldn't understand it.
5052
rawBody string
51-
// The request that failed.
52-
request *http.Request
5353
}
5454

5555
// Check that Error implements error
@@ -58,8 +58,8 @@ var _ error = (*Error)(nil)
5858
// Error implements error
5959
func (e *Error) Error() string {
6060
prefix := ""
61-
if e.request != nil {
62-
prefix = fmt.Sprintf("%s %s: ", e.request.Method, redactURL(e.request.URL))
61+
if e.Request != nil {
62+
prefix = fmt.Sprintf("%s %s: ", e.Request.Method, redactURL(e.Request.URL))
6363
}
6464
return prefix + e.responseErr()
6565
}
@@ -68,7 +68,7 @@ func (e *Error) responseErr() string {
6868
switch len(e.Errors) {
6969
case 0:
7070
if len(e.rawBody) == 0 {
71-
if e.request != nil && e.request.Method == http.MethodHead {
71+
if e.Request != nil && e.Request.Method == http.MethodHead {
7272
return fmt.Sprintf("unexpected status code %d %s (HEAD responses have no body, use GET for details)", e.StatusCode, http.StatusText(e.StatusCode))
7373
}
7474
return fmt.Sprintf("unexpected status code %d %s", e.StatusCode, http.StatusText(e.StatusCode))
@@ -194,7 +194,7 @@ func CheckError(resp *http.Response, codes ...int) error {
194194

195195
structuredError.rawBody = string(b)
196196
structuredError.StatusCode = resp.StatusCode
197-
structuredError.request = resp.Request
197+
structuredError.Request = resp.Request
198198

199199
return structuredError
200200
}

pkg/v1/remote/transport/retry.go

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,8 +46,11 @@ type options struct {
4646
predicate retry.Predicate
4747
}
4848

49+
// Backoff is an alias of retry.Backoff to expose this configuration option to consumers of this lib
50+
type Backoff = retry.Backoff
51+
4952
// WithRetryBackoff sets the backoff for retry operations.
50-
func WithRetryBackoff(backoff retry.Backoff) Option {
53+
func WithRetryBackoff(backoff Backoff) Option {
5154
return func(o *options) {
5255
o.backoff = backoff
5356
}

pkg/v1/remote/transport/transport.go

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -69,9 +69,9 @@ func NewWithContext(ctx context.Context, reg name.Registry, auth authn.Authentic
6969

7070
switch pr.challenge.Canonical() {
7171
case anonymous:
72-
return t, nil
72+
return &Wrapper{t}, nil
7373
case basic:
74-
return &basicTransport{inner: t, auth: auth, target: reg.RegistryStr()}, nil
74+
return &Wrapper{&basicTransport{inner: t, auth: auth, target: reg.RegistryStr()}}, nil
7575
case bearer:
7676
// We require the realm, which tells us where to send our Basic auth to turn it into Bearer auth.
7777
realm, ok := pr.parameters["realm"]
@@ -96,8 +96,19 @@ func NewWithContext(ctx context.Context, reg name.Registry, auth authn.Authentic
9696
if err := bt.refresh(ctx); err != nil {
9797
return nil, err
9898
}
99-
return bt, nil
99+
return &Wrapper{bt}, nil
100100
default:
101101
return nil, fmt.Errorf("unrecognized challenge: %s", pr.challenge)
102102
}
103103
}
104+
105+
// Wrapper results in *not* wrapping supplied transport with additional logic such as retries, useragent and debug logging
106+
// Consumers are opt-ing into providing their own transport without any additional wrapping.
107+
type Wrapper struct {
108+
inner http.RoundTripper
109+
}
110+
111+
// RoundTrip delegates to the inner RoundTripper
112+
func (w *Wrapper) RoundTrip(in *http.Request) (*http.Response, error) {
113+
return w.inner.RoundTrip(in)
114+
}

0 commit comments

Comments
 (0)