Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(internal/gensupport): add configurable retry #1324

Merged
merged 10 commits into from Dec 13, 2021
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.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
40 changes: 39 additions & 1 deletion google-api-go-generator/gen.go
Expand Up @@ -699,6 +699,9 @@ func (a *API) GenerateCode() ([]byte, error) {
pn(" %q", imp)
}
pn("")
if a.Name == "storage" {
pn(" %q", "github.com/googleapis/gax-go/v2")
}
for _, imp := range []struct {
pkg string
lname string
Expand Down Expand Up @@ -1838,6 +1841,9 @@ func (meth *Method) generateCode() {

if meth.supportsMediaUpload() {
pn(" mediaInfo_ *gensupport.MediaInfo")
if meth.api.Name == "storage" {
pn(" retry *gensupport.RetryConfig")
}
}
pn(" ctx_ context.Context")
pn(" header_ http.Header")
Expand Down Expand Up @@ -1986,6 +1992,32 @@ func (meth *Method) generateCode() {
pn("}")
}

if meth.supportsMediaUpload() && meth.api.Name == "storage" {
comment := "WithRetry causes the library to retry the initial request of the upload" +
"(for resumable uploads) or the entire upload (for multipart uploads) if" +
"a transient error occurs. This is contingent on ChunkSize being > 0 (so" +
"that the input data may be buffered). The backoff argument will be used to" +
"determine exponential backoff timing, and the errorFunc is used to determine" +
"which errors are considered retryable. By default, exponetial backoff will be" +
"applied using gax defaults, and the following errors are retried:" +
"\n\n" +
"- HTTP responses with codes 429, 502, 503, and 504." +
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could the error codes be pulled from gax instead of string literals?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seeing as this is just a one-off comment and these values are not likely to change, I don't think it's work adding extra complexity to the templating in order to accomplish this.

"\n\n" +
"- Transient network errors such as connection reset and io.ErrUnexpectedEOF." +
"\n\n" +
"- Errors which are considered transient using the Temporary() interface." +
"\n\n" +
"- Wrapped versions of these errors."
p("\n%s", asComment("", comment))
pn("func (c *%s) WithRetry(bo *gax.Backoff, errorFunc func(err error) bool) *%s {", callName, callName)
pn(" c.retry = &gensupport.RetryConfig{")
pn(" Backoff: bo,")
pn(" ShouldRetry: errorFunc,")
pn(" }")
pn(" return c")
pn("}")
}

comment := "Fields allows partial responses to be retrieved. " +
"See https://developers.google.com/gdata/docs/2.0/basics#PartialResponse " +
"for more information."
Expand Down Expand Up @@ -2106,7 +2138,10 @@ func (meth *Method) generateCode() {
pn(`})`)
}
if meth.supportsMediaUpload() && meth.api.Name == "storage" {
pn("return gensupport.SendRequestWithRetry(c.ctx_, c.s.client, req)")
pn("if c.retry != nil {")
pn(" return gensupport.SendRequestWithRetry(c.ctx_, c.s.client, req, c.retry)")
pn("}")
pn("return gensupport.SendRequest(c.ctx_, c.s.client, req)")
} else {
pn("return gensupport.SendRequest(c.ctx_, c.s.client, req)")
}
Expand Down Expand Up @@ -2172,6 +2207,9 @@ func (meth *Method) generateCode() {
pn("if rx != nil {")
pn(" rx.Client = c.s.client")
pn(" rx.UserAgent = c.s.userAgent()")
if meth.api.Name == "storage" {
pn(" rx.Retry = c.retry")
}
pn(" ctx := c.ctx_")
pn(" if ctx == nil {")
// TODO(mcgreevy): Require context when calling Media, or Do.
Expand Down
67 changes: 8 additions & 59 deletions internal/gensupport/resumable.go
Expand Up @@ -12,32 +12,6 @@ import (
"net/http"
"sync"
"time"

gax "github.com/googleapis/gax-go/v2"
)

// Backoff is an interface around gax.Backoff's Pause method, allowing tests to provide their
// own implementation.
type Backoff interface {
Pause() time.Duration
}

// These are declared as global variables so that tests can overwrite them.
var (
retryDeadline = 32 * time.Second
backoff = func() Backoff {
return &gax.Backoff{Initial: 100 * time.Millisecond}
}
// isRetryable is a platform-specific hook, specified in retryable_linux.go
syscallRetryable func(error) bool = func(err error) bool { return false }
)

const (
// statusTooManyRequests is returned by the storage API if the
// per-project limits have been temporarily exceeded. The request
// should be retried.
// https://cloud.google.com/storage/docs/json_api/v1/status-codes#standardcodes
statusTooManyRequests = 429
)

// ResumableUpload is used by the generated APIs to provide resumable uploads.
Expand All @@ -57,6 +31,9 @@ type ResumableUpload struct {

// Callback is an optional function that will be periodically called with the cumulative number of bytes uploaded.
Callback func(int64)

// Retry optionally configures retries for requests made against the upload.
Retry *RetryConfig
}

// Progress returns the number of bytes uploaded at this point.
Expand Down Expand Up @@ -176,13 +153,15 @@ func (rx *ResumableUpload) Upload(ctx context.Context) (resp *http.Response, err
}
return resp, nil
}
// Configure retryable error criteria.
errorFunc := rx.Retry.errorFunc()

// Send all chunks.
for {
var pause time.Duration

// Each chunk gets its own initialized-at-zero retry.
bo := backoff()
// Each chunk gets its own initialized-at-zero backoff.
bo := rx.Retry.backoff()
quitAfter := time.After(retryDeadline)

// Retry loop for a single chunk.
Expand All @@ -206,7 +185,7 @@ func (rx *ResumableUpload) Upload(ctx context.Context) (resp *http.Response, err
}

// Check if we should retry the request.
if !shouldRetry(status, err) {
if !errorFunc(status, err) {
break
}

Expand All @@ -226,33 +205,3 @@ func (rx *ResumableUpload) Upload(ctx context.Context) (resp *http.Response, err
return prepareReturn(resp, err)
}
}

// shouldRetry indicates whether an error is retryable for the purposes of this
// package, following guidance from
// https://cloud.google.com/storage/docs/exponential-backoff .
func shouldRetry(status int, err error) bool {
if 500 <= status && status <= 599 {
return true
}
if status == statusTooManyRequests {
return true
}
if err == io.ErrUnexpectedEOF {
return true
}
// Transient network errors should be retried.
if syscallRetryable(err) {
return true
}
if err, ok := err.(interface{ Temporary() bool }); ok {
if err.Temporary() {
return true
}
}
// If Go 1.13 error unwrapping is available, use this to examine wrapped
// errors.
if err, ok := err.(interface{ Unwrap() error }); ok {
return shouldRetry(status, err.Unwrap())
}
return false
}
106 changes: 106 additions & 0 deletions internal/gensupport/retry.go
@@ -0,0 +1,106 @@
// Copyright 2021 Google LLC.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.

package gensupport

import (
"io"
"time"

"github.com/googleapis/gax-go/v2"
"google.golang.org/api/googleapi"
)

// Backoff is an interface around gax.Backoff's Pause method, allowing tests to provide their
// own implementation.
type Backoff interface {
Pause() time.Duration
}

// These are declared as global variables so that tests can overwrite them.
var (
// Per-chunk deadline for resumable uploads.
retryDeadline = 32 * time.Second
// Default backoff timer.
backoff = func() Backoff {
return &gax.Backoff{Initial: 100 * time.Millisecond}
}
// syscallRetryable is a platform-specific hook, specified in retryable_linux.go
syscallRetryable func(error) bool = func(err error) bool { return false }
)

const (
// statusTooManyRequests is returned by the storage API if the
// per-project limits have been temporarily exceeded. The request
// should be retried.
// https://cloud.google.com/storage/docs/json_api/v1/status-codes#standardcodes
statusTooManyRequests = 429
)

// shouldRetry indicates whether an error is retryable for the purposes of this
// package, unless a ShouldRetry func is specified by the RetryConfig instead.
// It follows guidance from
// https://cloud.google.com/storage/docs/exponential-backoff .
func shouldRetry(status int, err error) bool {
if 500 <= status && status <= 599 {
return true
}
if status == statusTooManyRequests {
return true
}
if err == io.ErrUnexpectedEOF {
return true
}
// Transient network errors should be retried.
if syscallRetryable(err) {
return true
}
if err, ok := err.(interface{ Temporary() bool }); ok {
if err.Temporary() {
return true
}
}
// If Go 1.13 error unwrapping is available, use this to examine wrapped
// errors.
if err, ok := err.(interface{ Unwrap() error }); ok {
return shouldRetry(status, err.Unwrap())
}
return false
}

// RetryConfig allows configuration of backoff timing and retryable errors.
type RetryConfig struct {
Backoff *gax.Backoff
ShouldRetry func(err error) bool
}

// Get a new backoff object based on the configured values.
func (r *RetryConfig) backoff() Backoff {
if r == nil || r.Backoff == nil {
return backoff()
}
return &gax.Backoff{
Initial: r.Backoff.Initial,
Max: r.Backoff.Max,
Multiplier: r.Backoff.Multiplier,
}
}

// This is kind of hacky; it is necessary because ShouldRetry expects to
// handle HTTP errors via googleapi.Error, but the error has not yet been
// wrapped with a googleapi.Error at this layer, and the ErrorFunc type
// in the manual layer does not pass in a status explicitly as it does
// here. So, we must wrap error status codes in a googleapi.Error so that
// ShouldRetry can parse this correctly.
func (r *RetryConfig) errorFunc() func(status int, err error) bool {
if r == nil || r.ShouldRetry == nil {
return shouldRetry
}
return func(status int, err error) bool {
if status >= 400 {
return r.ShouldRetry(&googleapi.Error{Code: status})
}
return r.ShouldRetry(err)
}
}
23 changes: 18 additions & 5 deletions internal/gensupport/send.go
Expand Up @@ -10,6 +10,8 @@ import (
"errors"
"net/http"
"time"

"github.com/googleapis/gax-go/v2"
)

// SendRequest sends a single HTTP request using the given client.
Expand Down Expand Up @@ -50,7 +52,7 @@ func send(ctx context.Context, client *http.Client, req *http.Request) (*http.Re
// If ctx is non-nil, it calls all hooks, then sends the request with
// req.WithContext, then calls any functions returned by the hooks in
// reverse order.
func SendRequestWithRetry(ctx context.Context, client *http.Client, req *http.Request) (*http.Response, error) {
func SendRequestWithRetry(ctx context.Context, client *http.Client, req *http.Request, retry *RetryConfig) (*http.Response, error) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The only thing I think will be an issue is that the remote offset is not considered in the case Storage has progressed and the client is now unaligned.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, that work will have to come in a separate PR. This PR doesn't change the mechanics of the resumption strategy.

// Disallow Accept-Encoding because it interferes with the automatic gzip handling
// done by the default http.Transport. See https://github.com/google/google-api-go-client/issues/219.
if _, ok := req.Header["Accept-Encoding"]; ok {
Expand All @@ -59,10 +61,10 @@ func SendRequestWithRetry(ctx context.Context, client *http.Client, req *http.Re
if ctx == nil {
return client.Do(req)
}
return sendAndRetry(ctx, client, req)
return sendAndRetry(ctx, client, req, retry)
}

func sendAndRetry(ctx context.Context, client *http.Client, req *http.Request) (*http.Response, error) {
func sendAndRetry(ctx context.Context, client *http.Client, req *http.Request, retry *RetryConfig) (*http.Response, error) {
if client == nil {
client = http.DefaultClient
}
Expand All @@ -72,7 +74,18 @@ func sendAndRetry(ctx context.Context, client *http.Client, req *http.Request) (

// Loop to retry the request, up to the context deadline.
var pause time.Duration
bo := backoff()
var bo Backoff
if retry != nil && retry.Backoff != nil {
bo = &gax.Backoff{
Initial: retry.Backoff.Initial,
Max: retry.Backoff.Max,
Multiplier: retry.Backoff.Multiplier,
}
} else {
bo = backoff()
}

var errorFunc = retry.errorFunc()

for {
select {
Expand All @@ -96,7 +109,7 @@ func sendAndRetry(ctx context.Context, client *http.Client, req *http.Request) (
// Check if we can retry the request. A retry can only be done if the error
// is retryable and the request body can be re-created using GetBody (this
// will not be possible if the body was unbuffered).
if req.GetBody == nil || !shouldRetry(status, err) {
if req.GetBody == nil || !errorFunc(status, err) {
break
}
var errBody error
Expand Down
2 changes: 1 addition & 1 deletion internal/gensupport/send_test.go
Expand Up @@ -24,7 +24,7 @@ func TestSendRequestWithRetry(t *testing.T) {
// Setting Accept-Encoding should give an error immediately.
req, _ := http.NewRequest("GET", "url", nil)
req.Header.Set("Accept-Encoding", "")
_, err := SendRequestWithRetry(context.Background(), nil, req)
_, err := SendRequestWithRetry(context.Background(), nil, req, nil)
if err == nil {
t.Error("got nil, want error")
}
Expand Down