Skip to content

Commit

Permalink
feat(internal/gensupport): add configurable retry (#1324)
Browse files Browse the repository at this point in the history
Extend SendAndRetryRequest to allow retry options (backoff and
ErrorFunc) to be passed in. Add a surface to the generated code
for storage to allow the manual layer to choose whether the request
is retried.

I tested this out via the manual layer by verifying that the
ObjectsInsertCall only retries now when WithRetry() is applied.
  • Loading branch information
tritone committed Dec 13, 2021
1 parent 9d66b44 commit 8d2eca8
Show file tree
Hide file tree
Showing 7 changed files with 203 additions and 68 deletions.
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." +
"\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) {
// 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

0 comments on commit 8d2eca8

Please sign in to comment.