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 }