go.chromium.org/luci@v0.0.0-20240309015107-7cdc2e660f33/common/api/internal/gensupport/resumable.go (about)

     1  // Copyright 2016 The Go Authors. All rights reserved.
     2  // Use of this source code is governed by a BSD-style
     3  // license that can be found in the LICENSE file.
     4  
     5  package gensupport
     6  
     7  import (
     8  	"context"
     9  	"errors"
    10  	"fmt"
    11  	"io"
    12  	"net/http"
    13  	"strings"
    14  	"sync"
    15  	"time"
    16  
    17  	"github.com/google/uuid"
    18  )
    19  
    20  // ResumableUpload is used by the generated APIs to provide resumable uploads.
    21  // It is not used by developers directly.
    22  type ResumableUpload struct {
    23  	Client *http.Client
    24  	// URI is the resumable resource destination provided by the server after specifying "&uploadType=resumable".
    25  	URI       string
    26  	UserAgent string // User-Agent for header of the request
    27  	// Media is the object being uploaded.
    28  	Media *MediaBuffer
    29  	// MediaType defines the media type, e.g. "image/jpeg".
    30  	MediaType string
    31  
    32  	mu       sync.Mutex // guards progress
    33  	progress int64      // number of bytes uploaded so far
    34  
    35  	// Callback is an optional function that will be periodically called with the cumulative number of bytes uploaded.
    36  	Callback func(int64)
    37  
    38  	// Retry optionally configures retries for requests made against the upload.
    39  	Retry *RetryConfig
    40  
    41  	// ChunkRetryDeadline configures the per-chunk deadline after which no further
    42  	// retries should happen.
    43  	ChunkRetryDeadline time.Duration
    44  
    45  	// Track current request invocation ID and attempt count for retry metrics
    46  	// and idempotency headers.
    47  	invocationID string
    48  	attempts     int
    49  }
    50  
    51  // Progress returns the number of bytes uploaded at this point.
    52  func (rx *ResumableUpload) Progress() int64 {
    53  	rx.mu.Lock()
    54  	defer rx.mu.Unlock()
    55  	return rx.progress
    56  }
    57  
    58  // doUploadRequest performs a single HTTP request to upload data.
    59  // off specifies the offset in rx.Media from which data is drawn.
    60  // size is the number of bytes in data.
    61  // final specifies whether data is the final chunk to be uploaded.
    62  func (rx *ResumableUpload) doUploadRequest(ctx context.Context, data io.Reader, off, size int64, final bool) (*http.Response, error) {
    63  	req, err := http.NewRequest("POST", rx.URI, data)
    64  	if err != nil {
    65  		return nil, err
    66  	}
    67  
    68  	req.ContentLength = size
    69  	var contentRange string
    70  	if final {
    71  		if size == 0 {
    72  			contentRange = fmt.Sprintf("bytes */%v", off)
    73  		} else {
    74  			contentRange = fmt.Sprintf("bytes %v-%v/%v", off, off+size-1, off+size)
    75  		}
    76  	} else {
    77  		contentRange = fmt.Sprintf("bytes %v-%v/*", off, off+size-1)
    78  	}
    79  	req.Header.Set("Content-Range", contentRange)
    80  	req.Header.Set("Content-Type", rx.MediaType)
    81  	req.Header.Set("User-Agent", rx.UserAgent)
    82  
    83  	// TODO(b/274504690): Consider dropping gccl-invocation-id key since it
    84  	// duplicates the X-Goog-Gcs-Idempotency-Token header (added in v0.115.0).
    85  	baseXGoogHeader := "gl-go/" + GoVersion() + " gdcl/" + "luci-go"
    86  	invocationHeader := fmt.Sprintf("gccl-invocation-id/%s gccl-attempt-count/%d", rx.invocationID, rx.attempts)
    87  	req.Header.Set("X-Goog-Api-Client", strings.Join([]string{baseXGoogHeader, invocationHeader}, " "))
    88  
    89  	// Set idempotency token header which is used by GCS uploads.
    90  	req.Header.Set("X-Goog-Gcs-Idempotency-Token", rx.invocationID)
    91  
    92  	// Google's upload endpoint uses status code 308 for a
    93  	// different purpose than the "308 Permanent Redirect"
    94  	// since-standardized in RFC 7238. Because of the conflict in
    95  	// semantics, Google added this new request header which
    96  	// causes it to not use "308" and instead reply with 200 OK
    97  	// and sets the upload-specific "X-HTTP-Status-Code-Override:
    98  	// 308" response header.
    99  	req.Header.Set("X-GUploader-No-308", "yes")
   100  
   101  	return SendRequest(ctx, rx.Client, req)
   102  }
   103  
   104  func statusResumeIncomplete(resp *http.Response) bool {
   105  	// This is how the server signals "status resume incomplete"
   106  	// when X-GUploader-No-308 is set to "yes":
   107  	return resp != nil && resp.Header.Get("X-Http-Status-Code-Override") == "308"
   108  }
   109  
   110  // reportProgress calls a user-supplied callback to report upload progress.
   111  // If old==updated, the callback is not called.
   112  func (rx *ResumableUpload) reportProgress(old, updated int64) {
   113  	if updated-old == 0 {
   114  		return
   115  	}
   116  	rx.mu.Lock()
   117  	rx.progress = updated
   118  	rx.mu.Unlock()
   119  	if rx.Callback != nil {
   120  		rx.Callback(updated)
   121  	}
   122  }
   123  
   124  // transferChunk performs a single HTTP request to upload a single chunk from rx.Media.
   125  func (rx *ResumableUpload) transferChunk(ctx context.Context) (*http.Response, error) {
   126  	chunk, off, size, err := rx.Media.Chunk()
   127  
   128  	done := err == io.EOF
   129  	if !done && err != nil {
   130  		return nil, err
   131  	}
   132  
   133  	res, err := rx.doUploadRequest(ctx, chunk, off, int64(size), done)
   134  	if err != nil {
   135  		return res, err
   136  	}
   137  
   138  	// We sent "X-GUploader-No-308: yes" (see comment elsewhere in
   139  	// this file), so we don't expect to get a 308.
   140  	if res.StatusCode == 308 {
   141  		return nil, errors.New("unexpected 308 response status code")
   142  	}
   143  
   144  	if res.StatusCode == http.StatusOK {
   145  		rx.reportProgress(off, off+int64(size))
   146  	}
   147  
   148  	if statusResumeIncomplete(res) {
   149  		rx.Media.Next()
   150  	}
   151  	return res, nil
   152  }
   153  
   154  // Upload starts the process of a resumable upload with a cancellable context.
   155  // It retries using the provided back off strategy until cancelled or the
   156  // strategy indicates to stop retrying.
   157  // It is called from the auto-generated API code and is not visible to the user.
   158  // Before sending an HTTP request, Upload calls any registered hook functions,
   159  // and calls the returned functions after the request returns (see send.go).
   160  // rx is private to the auto-generated API code.
   161  // Exactly one of resp or err will be nil.  If resp is non-nil, the caller must call resp.Body.Close.
   162  func (rx *ResumableUpload) Upload(ctx context.Context) (resp *http.Response, err error) {
   163  
   164  	// There are a couple of cases where it's possible for err and resp to both
   165  	// be non-nil. However, we expose a simpler contract to our callers: exactly
   166  	// one of resp and err will be non-nil. This means that any response body
   167  	// must be closed here before returning a non-nil error.
   168  	var prepareReturn = func(resp *http.Response, err error) (*http.Response, error) {
   169  		if err != nil {
   170  			if resp != nil && resp.Body != nil {
   171  				resp.Body.Close()
   172  			}
   173  			return nil, err
   174  		}
   175  		// This case is very unlikely but possible only if rx.ChunkRetryDeadline is
   176  		// set to a very small value, in which case no requests will be sent before
   177  		// the deadline. Return an error to avoid causing a panic.
   178  		if resp == nil {
   179  			return nil, fmt.Errorf("upload request to %v not sent, choose larger value for ChunkRetryDealine", rx.URI)
   180  		}
   181  		return resp, nil
   182  	}
   183  	// Configure retryable error criteria.
   184  	errorFunc := rx.Retry.errorFunc()
   185  
   186  	// Configure per-chunk retry deadline.
   187  	var retryDeadline time.Duration
   188  	if rx.ChunkRetryDeadline != 0 {
   189  		retryDeadline = rx.ChunkRetryDeadline
   190  	} else {
   191  		retryDeadline = defaultRetryDeadline
   192  	}
   193  
   194  	// Send all chunks.
   195  	for {
   196  		var pause time.Duration
   197  
   198  		// Each chunk gets its own initialized-at-zero backoff and invocation ID.
   199  		bo := rx.Retry.backoff()
   200  		quitAfterTimer := time.NewTimer(retryDeadline)
   201  		rx.attempts = 1
   202  		rx.invocationID = uuid.New().String()
   203  
   204  		// Retry loop for a single chunk.
   205  		for {
   206  			pauseTimer := time.NewTimer(pause)
   207  			select {
   208  			case <-ctx.Done():
   209  				quitAfterTimer.Stop()
   210  				pauseTimer.Stop()
   211  				if err == nil {
   212  					err = ctx.Err()
   213  				}
   214  				return prepareReturn(resp, err)
   215  			case <-pauseTimer.C:
   216  			case <-quitAfterTimer.C:
   217  				pauseTimer.Stop()
   218  				return prepareReturn(resp, err)
   219  			}
   220  			pauseTimer.Stop()
   221  
   222  			// Check for context cancellation or timeout once more. If more than one
   223  			// case in the select statement above was satisfied at the same time, Go
   224  			// will choose one arbitrarily.
   225  			// That can cause an operation to go through even if the context was
   226  			// canceled before or the timeout was reached.
   227  			select {
   228  			case <-ctx.Done():
   229  				quitAfterTimer.Stop()
   230  				if err == nil {
   231  					err = ctx.Err()
   232  				}
   233  				return prepareReturn(resp, err)
   234  			case <-quitAfterTimer.C:
   235  				return prepareReturn(resp, err)
   236  			default:
   237  			}
   238  
   239  			resp, err = rx.transferChunk(ctx)
   240  
   241  			var status int
   242  			if resp != nil {
   243  				status = resp.StatusCode
   244  			}
   245  
   246  			// Check if we should retry the request.
   247  			if !errorFunc(status, err) {
   248  				quitAfterTimer.Stop()
   249  				break
   250  			}
   251  
   252  			rx.attempts++
   253  			pause = bo.Pause()
   254  			if resp != nil && resp.Body != nil {
   255  				resp.Body.Close()
   256  			}
   257  		}
   258  
   259  		// If the chunk was uploaded successfully, but there's still
   260  		// more to go, upload the next chunk without any delay.
   261  		if statusResumeIncomplete(resp) {
   262  			resp.Body.Close()
   263  			continue
   264  		}
   265  
   266  		return prepareReturn(resp, err)
   267  	}
   268  }