github.com/bitrise-io/go-steputils/v2@v2.0.0-alpha.30/cache/network/download_test.go (about)

     1  package network
     2  
     3  import (
     4  	"context"
     5  	"encoding/json"
     6  	"errors"
     7  	"fmt"
     8  	"net/http"
     9  	"net/http/httptest"
    10  	"os"
    11  	"path/filepath"
    12  	"strconv"
    13  	"strings"
    14  	"sync/atomic"
    15  	"testing"
    16  
    17  	"github.com/bitrise-io/go-utils/v2/log"
    18  	"github.com/bitrise-io/go-utils/v2/mocks"
    19  	"github.com/bitrise-io/go-utils/v2/retryhttp"
    20  	"github.com/docker/go-units"
    21  	"github.com/hashicorp/go-retryablehttp"
    22  	"github.com/stretchr/testify/assert"
    23  	"github.com/stretchr/testify/mock"
    24  	"github.com/stretchr/testify/require"
    25  )
    26  
    27  func customRetryFunction(ctx context.Context, resp *http.Response, err error) (bool, error) {
    28  	return retryablehttp.DefaultRetryPolicy(ctx, resp, err)
    29  }
    30  
    31  func TestCreateCustomRetryFunction(t *testing.T) {
    32  	cases := []struct {
    33  		name     string
    34  		response *http.Response
    35  		ctx      context.Context
    36  
    37  		error    error
    38  		expected bool
    39  	}{
    40  		{
    41  			name:     "Retry for retriable error",
    42  			response: &http.Response{},
    43  			error:    errors.New("EOF"),
    44  			expected: true,
    45  		},
    46  		{
    47  			name:     "Retry for any error",
    48  			response: &http.Response{},
    49  			error:    errors.New("non-pattern-matching-error"),
    50  			expected: true,
    51  		},
    52  		{
    53  			name:     "Retry for retriable error",
    54  			response: &http.Response{},
    55  			error:    errors.New("Range request returned invalid Content-Length"),
    56  			expected: true,
    57  		},
    58  		{
    59  			name:     "No retry for HTTP 404 status code",
    60  			response: &http.Response{StatusCode: 404},
    61  			error:    nil,
    62  			expected: false,
    63  		},
    64  		{
    65  			name:     "Retry, even though the status is non-retriable in default policy",
    66  			response: &http.Response{StatusCode: 404},
    67  			error:    errors.New("Range request returned invalid Content-Length"),
    68  			expected: true,
    69  		},
    70  		{
    71  			name:     "Retry, even though the status is 404 and error pattern isnt matching",
    72  			response: &http.Response{StatusCode: 404},
    73  			error:    errors.New("non-pattern-matching-error"),
    74  			expected: true,
    75  		},
    76  		{
    77  			name:     "Retry for HTTP 429 status code",
    78  			response: &http.Response{StatusCode: 429},
    79  			error:    nil,
    80  			expected: true,
    81  		},
    82  		{
    83  			name:     "Retry for HTTP 500 status code",
    84  			response: &http.Response{StatusCode: 500},
    85  			error:    nil,
    86  			expected: true,
    87  		},
    88  	}
    89  
    90  	for _, tc := range cases {
    91  		t.Run(tc.name, func(t *testing.T) {
    92  			retry, _ := customRetryFunction(context.Background(), tc.response, tc.error)
    93  			assert.Equal(t, tc.expected, retry)
    94  		})
    95  	}
    96  }
    97  
    98  func Test_downloadFile_multipart_retrycheck(t *testing.T) {
    99  	// Given
   100  	mockLogger := new(mocks.Logger)
   101  	mockLogger.On("Debugf", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return()
   102  
   103  	retryableHTTPClient := retryhttp.NewClient(mockLogger)
   104  
   105  	var isCheckRetryCalled atomic.Bool
   106  	retryFunc := func(ctx context.Context, resp *http.Response, downloadErr error) (bool, error) {
   107  		retry, err := retryablehttp.DefaultRetryPolicy(ctx, resp, downloadErr)
   108  		isCheckRetryCalled.Store(true)
   109  		return retry, err
   110  	}
   111  	retryableHTTPClient.CheckRetry = retryFunc
   112  
   113  	tmpPath := t.TempDir()
   114  	tmpFile := filepath.Join(tmpPath, "testfile.bin")
   115  	testDummyFileContent := strings.Repeat("a", 10*units.MB) // 10MB
   116  
   117  	svr := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
   118  		t.Logf("Server called. Method=%s; Header=%#v", r.Method, r.Header)
   119  		rangeHeader := r.Header.Get("Range")
   120  		if len(rangeHeader) < 1 {
   121  			t.Fatal("No Range header found")
   122  		}
   123  
   124  		if !strings.HasPrefix(rangeHeader, "bytes=") {
   125  			t.Fatalf("invalid range header: should start with 'bytes=' ; actual range header value was=%s", rangeHeader)
   126  		}
   127  		rangeHeader = strings.TrimPrefix(rangeHeader, "bytes=")
   128  		rangeHeaderFromTo := strings.Split(rangeHeader, "-")
   129  		if len(rangeHeaderFromTo) != 2 {
   130  			t.Fatalf("invalid range header: invalid from-to value. Range header value was=%s", rangeHeader)
   131  		}
   132  		rangeHeaderFrom, err := strconv.ParseUint(rangeHeaderFromTo[0], 10, 64)
   133  		require.NoError(t, err)
   134  		rangeHeaderTo, err := strconv.ParseUint(rangeHeaderFromTo[1], 10, 64)
   135  		require.NoError(t, err)
   136  
   137  		if rangeHeaderFrom == 0 && rangeHeaderTo == 0 {
   138  			// range request - requesting content size - return the size info
   139  			w.Header().Add("content-range", fmt.Sprintf("bytes 0-0/%d", len(testDummyFileContent)))
   140  			_, err := fmt.Fprint(w, " ")
   141  			require.NoError(t, err)
   142  		} else {
   143  			// actual content chunk request - return chunk content
   144  			chunkContent := testDummyFileContent[rangeHeaderFrom : rangeHeaderTo+1]
   145  			// We also have to set the Content-Length header manually due to the size of the response.
   146  			// From the documentation of http.ResponseWriter:
   147  			// > ... if the total size of all written
   148  			// > data is under a few KB and there are no Flush calls, the
   149  			// > Content-Length header is added automatically.
   150  			w.Header().Add("Content-Length", fmt.Sprintf("%d", len(chunkContent)))
   151  			_, err := fmt.Fprint(w, chunkContent)
   152  			require.NoError(t, err)
   153  		}
   154  	}))
   155  	defer svr.Close()
   156  	downloadURL := svr.URL
   157  
   158  	// When
   159  	err := downloadFile(context.Background(), retryableHTTPClient.StandardClient(), downloadURL, tmpFile)
   160  
   161  	// Then
   162  	require.True(t, isCheckRetryCalled.Load())
   163  	require.NoError(t, err)
   164  	mockLogger.AssertExpectations(t)
   165  }
   166  
   167  func Test_downloadWithClient_WhenNoChunksError_ThenWillDoFullRetry(t *testing.T) {
   168  	// Given
   169  	logger := log.NewLogger()
   170  	logger.EnableDebugLog(true)
   171  
   172  	retryableHTTPClient := retryhttp.NewClient(logger)
   173  	retryFunc := func(ctx context.Context, resp *http.Response, downloadErr error) (bool, error) {
   174  		return false, downloadErr // Disable retries
   175  	}
   176  	retryableHTTPClient.CheckRetry = retryFunc
   177  
   178  	var numErrorsLeft atomic.Int64
   179  	numErrorsLeft.Store(2)
   180  
   181  	tmpPath := t.TempDir()
   182  	tmpFile := filepath.Join(tmpPath, "testfile.bin")
   183  	testDummyFileContent := strings.Repeat("a", 10*units.MB) // 10MB
   184  	cacheKey := "test-cache-key"
   185  
   186  	fileServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
   187  		t.Logf("[fileserver] Server called. Method=%s; Header=%#v", r.Method, r.Header)
   188  		if numErrorsLeft.Load() > 0 {
   189  			numErrorsLeft.Add(-1)
   190  			w.WriteHeader(http.StatusInternalServerError)
   191  
   192  			return
   193  		}
   194  
   195  		// We also have to set the Content-Length header manually due to the size of the response.
   196  		// From the documentation of http.ResponseWriter:
   197  		// > ... if the total size of all written
   198  		// > data is under a few KB and there are no Flush calls, the
   199  		// > Content-Length header is added automatically.
   200  		w.Header().Add("Content-Length", fmt.Sprintf("%d", len(testDummyFileContent)))
   201  		_, err := fmt.Fprint(w, testDummyFileContent)
   202  		require.NoError(t, err)
   203  	}))
   204  	defer fileServer.Close()
   205  
   206  	apiServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
   207  		t.Logf("[apiserver] Server called. Path=%s; Method=%s; Query=%s; Header=%#v", r.URL.Path, r.Method, r.URL.Query(), r.Header)
   208  
   209  		resp := restoreResponse{
   210  			URL:        fileServer.URL,
   211  			MatchedKey: cacheKey,
   212  		}
   213  
   214  		w.WriteHeader(http.StatusOK)
   215  		w.Header().Add("Content-Type", "application/json")
   216  		err := json.NewEncoder(w).Encode(resp)
   217  		require.NoError(t, err)
   218  	}))
   219  	defer apiServer.Close()
   220  
   221  	downloadParams := DownloadParams{
   222  		APIBaseURL:     apiServer.URL,
   223  		Token:          "netok",
   224  		CacheKeys:      []string{cacheKey},
   225  		DownloadPath:   tmpFile,
   226  		NumFullRetries: 3,
   227  	}
   228  
   229  	// When
   230  	gotMatchedKeys, err := downloadWithClient(context.Background(), retryableHTTPClient, downloadParams, logger)
   231  	// Then
   232  	require.NoError(t, err)
   233  	require.Equal(t, "test-cache-key", gotMatchedKeys)
   234  
   235  	downloadedContents, err := os.ReadFile(tmpFile) // Read back downloaded file
   236  	require.NoError(t, err)
   237  	require.Equal(t, testDummyFileContent, string(downloadedContents), "Contents should match")
   238  	require.Equal(t, numErrorsLeft.Load(), int64(0), "Numbers of retries is number errors + the final successful attempt")
   239  }
   240  
   241  func Test_downloadWithClient_WhenCacheKeyNotFound_ThenWillNotRetry(t *testing.T) {
   242  	// Given
   243  	logger := log.NewLogger()
   244  	logger.EnableDebugLog(true)
   245  
   246  	retryableHTTPClient := retryhttp.NewClient(logger)
   247  	retryFunc := func(ctx context.Context, resp *http.Response, downloadErr error) (bool, error) {
   248  		retry, err := retryablehttp.DefaultRetryPolicy(ctx, resp, downloadErr)
   249  		require.False(t, retry, "Should not retry")
   250  		return retry, err
   251  	}
   252  	retryableHTTPClient.CheckRetry = retryFunc
   253  
   254  	var apiServerCalled atomic.Uint64
   255  	apiServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
   256  		t.Logf("[apiserver] Server called. Path=%s; Method=%s; Query=%s; Header=%#v", r.URL.Path, r.Method, r.URL.Query(), r.Header)
   257  		apiServerCalled.Add(1)
   258  		w.WriteHeader(http.StatusNotFound)
   259  	}))
   260  	defer apiServer.Close()
   261  
   262  	downloadParams := DownloadParams{
   263  		APIBaseURL:     apiServer.URL,
   264  		Token:          "netok",
   265  		CacheKeys:      []string{"test-cache--key"},
   266  		DownloadPath:   "",
   267  		NumFullRetries: 3,
   268  	}
   269  
   270  	// When
   271  	gotMatchedKeys, err := downloadWithClient(context.Background(), retryableHTTPClient, downloadParams, logger)
   272  	// Then
   273  	require.ErrorContains(t, err, "no cache archive found for the provided keys")
   274  	require.Equal(t, "", gotMatchedKeys)
   275  
   276  	require.Equal(t, uint64(1), apiServerCalled.Load(), "no retries were done")
   277  }