diff --git a/cdp-sdk-go/cdp/backoff.go b/cdp-sdk-go/cdp/backoff.go new file mode 100644 index 00000000..efccfa36 --- /dev/null +++ b/cdp-sdk-go/cdp/backoff.go @@ -0,0 +1,51 @@ +// Copyright 2024 Cloudera. All Rights Reserved. +// +// This file is licensed under the Apache License Version 2.0 (the "License"). +// You may not use this file except in compliance with the License. +// You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0. +// +// This file is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS +// OF ANY KIND, either express or implied. Refer to the License for the specific +// permissions and limitations governing your use of the file. + +package cdp + +import ( + "log" + "math" + "math/rand" + "os" + "time" +) + +const ( + expDeltaMin = 0.75 + expDeltaMax = 1.0 +) + +func backoff(retries int) time.Duration { + switch os.Getenv("CDP_TF_BACKOFF_STRATEGY") { + case "linear": + { + step := intFromEnvOrDefault("CDP_TF_BACKOFF_STEP", defaultLinearBackoffStep) + log.Default().Println("Using linear backoff strategy with step: ", step) + return linearBackoff(retries, step) + } + default: + { + log.Default().Println("Using exponential backoff strategy") + return exponentialBackoff(retries) + } + } +} + +func exponentialBackoff(retries int) time.Duration { + rndSrc := rand.NewSource(time.Now().UnixNano()) + delta := expDeltaMax - expDeltaMin + jitter := expDeltaMin + rand.New(rndSrc).Float64()*(delta) + return time.Duration((math.Pow(2, float64(retries))*jitter)*float64(time.Millisecond)) * 1000 +} + +func linearBackoff(retries int, step int) time.Duration { + return time.Duration((retries+1)*step) * time.Second +} diff --git a/cdp-sdk-go/cdp/backoff_test.go b/cdp-sdk-go/cdp/backoff_test.go new file mode 100644 index 00000000..baf3df63 --- /dev/null +++ b/cdp-sdk-go/cdp/backoff_test.go @@ -0,0 +1,144 @@ +// Copyright 2024 Cloudera. All Rights Reserved. +// +// This file is licensed under the Apache License Version 2.0 (the "License"). +// You may not use this file except in compliance with the License. +// You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0. +// +// This file is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS +// OF ANY KIND, either express or implied. Refer to the License for the specific +// permissions and limitations governing your use of the file. + +package cdp + +import ( + "os" + "testing" + "time" +) + +func TestLinearBackoffWithPositiveRetries(t *testing.T) { + retries := 3 + step := 2 + expected := 8 * time.Second + + result := linearBackoff(retries, step) + if result != expected { + t.Fatalf("Expected %v, got %v", expected, result) + } +} + +func TestLinearBackoffWithZeroRetries(t *testing.T) { + retries := 0 + step := 2 + expected := 2 * time.Second + + result := linearBackoff(retries, step) + if result != expected { + t.Fatalf("Expected %v, got %v", expected, result) + } +} + +func TestLinearBackoffWithNegativeRetries(t *testing.T) { + retries := -1 + step := 2 + expected := 0 * time.Second + + result := linearBackoff(retries, step) + if result != expected { + t.Fatalf("Expected %v, got %v", expected, result) + } +} + +func TestLinearBackoffWithLargeStep(t *testing.T) { + retries := 2 + step := 1000 + expected := 3000 * time.Second + + result := linearBackoff(retries, step) + if result != expected { + t.Fatalf("Expected %v, got %v", expected, result) + } +} + +func TestExponentialBackoffWithPositiveRetries(t *testing.T) { + retries := 3 + expectedMin := 6 * time.Second + expectedMax := 8 * time.Second + + result := exponentialBackoff(retries) + if result < expectedMin || result > expectedMax { + t.Fatalf("Expected between %v and %v, got %v", expectedMin, expectedMax, result) + } +} + +func TestExponentialBackoffWithZeroRetries(t *testing.T) { + retries := 0 + expectedMin := 0.75 * float64(time.Second) + expectedMax := 1 * time.Second + + result := exponentialBackoff(retries) + if result < time.Duration(int(expectedMin)) || result > expectedMax { + t.Fatalf("Expected between %v and %v, got %v", time.Duration(int(expectedMin)), expectedMax, result) + } +} + +func TestExponentialBackoffWithHighRetries(t *testing.T) { + retries := 10 + expectedMin := 768 * time.Second + expectedMax := 1024 * time.Second + + result := exponentialBackoff(retries) + if result < expectedMin || result > expectedMax { + t.Fatalf("Expected between %v and %v, got %v", expectedMin, expectedMax, result) + } +} + +func TestLinearBackoffStrategyWithDefaultStep(t *testing.T) { + _ = os.Setenv("CDP_TF_BACKOFF_STRATEGY", "linear") + defer func() { + _ = os.Unsetenv("CDP_TF_BACKOFF_STRATEGY") + }() + + result := backoff(2) + expected := defaultLinearBackoffStep * 3 * time.Second + if result != expected { + t.Fatalf("Expected %v, got %v", expected, result) + } +} + +func TestExponentialBackoffStrategyWithDefault(t *testing.T) { + _ = os.Unsetenv("CDP_TF_BACKOFF_STRATEGY") + + result := backoff(3) + expectedMin := 6 * time.Second + expectedMax := 8 * time.Second + if result < expectedMin || result > expectedMax { + t.Fatalf("Expected between %v and %v, got %v", expectedMin, expectedMax, result) + } +} + +func TestLinearBackoffStrategyWithCustomStep(t *testing.T) { + _ = os.Setenv("CDP_TF_BACKOFF_STRATEGY", "linear") + _ = os.Setenv("CDP_TF_BACKOFF_STEP", "5") + defer func() { + _ = os.Unsetenv("CDP_TF_BACKOFF_STRATEGY") + _ = os.Unsetenv("CDP_TF_BACKOFF_STEP") + }() + + result := backoff(1) + expected := 10 * time.Second + if result != expected { + t.Fatalf("Expected %v, got %v", expected, result) + } +} + +func TestExponentialBackoffStrategyWithHighRetries(t *testing.T) { + _ = os.Unsetenv("CDP_TF_BACKOFF_STRATEGY") + + result := backoff(10) + expectedMin := 768 * time.Second + expectedMax := 1024 * time.Second + if result < expectedMin || result > expectedMax { + t.Fatalf("Expected between %v and %v, got %v", expectedMin, expectedMax, result) + } +} diff --git a/cdp-sdk-go/cdp/retryable_transport.go b/cdp-sdk-go/cdp/retryable_transport.go new file mode 100644 index 00000000..694d32fc --- /dev/null +++ b/cdp-sdk-go/cdp/retryable_transport.go @@ -0,0 +1,68 @@ +// Copyright 2024 Cloudera. All Rights Reserved. +// +// This file is licensed under the Apache License Version 2.0 (the "License"). +// You may not use this file except in compliance with the License. +// You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0. +// +// This file is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS +// OF ANY KIND, either express or implied. Refer to the License for the specific +// permissions and limitations governing your use of the file. + +package cdp + +import ( + "bytes" + "fmt" + "io" + "log" + "net/http" + "time" +) + +type RetryableTransport struct { + transport http.RoundTripper +} + +func shouldRetry(err error, resp *http.Response) bool { + if err != nil { + return true + } else if resp == nil { + return false + } + return sliceContains(retryableStatusCodes, resp.StatusCode) +} + +func drainBody(resp *http.Response) { + if resp != nil && resp.Body != nil { + _, err := io.Copy(io.Discard, resp.Body) + if err != nil { + log.Default().Println("Error while draining body: ", err) + } + defer func(Body io.ReadCloser) { + _ = Body.Close() + }(resp.Body) + } +} + +func (t *RetryableTransport) RoundTrip(req *http.Request) (*http.Response, error) { + var bodyBytes []byte + if req.Body != nil { + bodyBytes, _ = io.ReadAll(req.Body) + req.Body = io.NopCloser(bytes.NewBuffer(bodyBytes)) + } + resp, err := t.transport.RoundTrip(req) + retries := 0 + retryCount := intFromEnvOrDefault("CDP_TF_CALL_RETRY_COUNT", 10) + for shouldRetry(err, resp) && retries < retryCount { + log.Default().Printf("Retrying request (caused by: %+v;%+v)\n", err, resp) + time.Sleep(backoff(retries)) + drainBody(resp) + if req.Body != nil { + req.Body = io.NopCloser(bytes.NewBuffer(bodyBytes)) + } + resp, err = t.transport.RoundTrip(req) + fmt.Printf("%v retry out of %v\n", retries+1, retryCount) + retries++ + } + return resp, err +} diff --git a/cdp-sdk-go/cdp/retryable_transport_test.go b/cdp-sdk-go/cdp/retryable_transport_test.go new file mode 100644 index 00000000..d4b1449b --- /dev/null +++ b/cdp-sdk-go/cdp/retryable_transport_test.go @@ -0,0 +1,174 @@ +// Copyright 2024 Cloudera. All Rights Reserved. +// +// This file is licensed under the Apache License Version 2.0 (the "License"). +// You may not use this file except in compliance with the License. +// You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0. +// +// This file is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS +// OF ANY KIND, either express or implied. Refer to the License for the specific +// permissions and limitations governing your use of the file. + +package cdp + +import ( + "bytes" + "fmt" + "github.com/jarcoal/httpmock" + "io" + "net/http" + "os" + "testing" +) + +const ( + testUrl = "https://example.com" + testGetHttpMethod = "GET" + testPostHttpMethod = "POST" + testEmptyBody = "" + testRetryCountEnvVariableKey = "CDP_TF_CALL_RETRY_COUNT" + testRetryCount = 3 +) + +type mockReadCloser struct { + closed bool +} + +func (m *mockReadCloser) Read(p []byte) (int, error) { + return 0, io.EOF +} + +func (m *mockReadCloser) Close() error { + m.closed = true + return nil +} + +func TestRoundTripRetriesOnError(t *testing.T) { + _ = os.Setenv(testRetryCountEnvVariableKey, fmt.Sprintf("%v", testRetryCount)) + defer func() { + _ = os.Unsetenv(testRetryCountEnvVariableKey) + }() + httpmock.Activate() + defer httpmock.DeactivateAndReset() + + httpmock.RegisterResponder(testGetHttpMethod, testUrl, + httpmock.NewErrorResponder(fmt.Errorf("network error"))) + + req, _ := http.NewRequest(testGetHttpMethod, testUrl, nil) + retryableTransport := &RetryableTransport{transport: http.DefaultTransport} + + resp, err := retryableTransport.RoundTrip(req) + if err == nil { + t.Fatalf("Expected error, got nil") + } + if resp != nil { + t.Fatalf("Expected nil response, got %v", resp) + } + if httpmock.GetTotalCallCount() != testRetryCount+1 { + t.Fatalf("Expected %v retries, got %v", testRetryCount, httpmock.GetTotalCallCount()-1) + } +} + +func TestRoundTripRetriesOnRetryableStatusCode(t *testing.T) { + _ = os.Setenv(testRetryCountEnvVariableKey, fmt.Sprintf("%v", testRetryCount)) + defer func() { + _ = os.Unsetenv(testRetryCountEnvVariableKey) + }() + httpmock.Activate() + defer httpmock.DeactivateAndReset() + + httpmock.RegisterResponder(testGetHttpMethod, testUrl, + httpmock.NewStringResponder(http.StatusTooManyRequests, testEmptyBody)) + + req, _ := http.NewRequest(testGetHttpMethod, testUrl, nil) + retryableTransport := &RetryableTransport{transport: http.DefaultTransport} + + resp, err := retryableTransport.RoundTrip(req) + if err != nil { + t.Fatalf("Expected nil error, got %v", err) + } + if resp.StatusCode != http.StatusTooManyRequests { + t.Fatalf("Expected status code %v, got %v", http.StatusTooManyRequests, resp.StatusCode) + } + if httpmock.GetTotalCallCount() != testRetryCount+1 { + t.Fatalf("Expected %v retries, got %v", testRetryCount, httpmock.GetTotalCallCount()-1) + } +} + +func TestRoundTripNoRetryOnSuccess(t *testing.T) { + _ = os.Setenv(testRetryCountEnvVariableKey, fmt.Sprintf("%v", testRetryCount)) + defer func() { + _ = os.Unsetenv(testRetryCountEnvVariableKey) + }() + httpmock.Activate() + defer httpmock.DeactivateAndReset() + + httpmock.RegisterResponder(testGetHttpMethod, testUrl, + httpmock.NewStringResponder(http.StatusOK, testEmptyBody)) + + req, _ := http.NewRequest(testGetHttpMethod, testUrl, nil) + retryableTransport := &RetryableTransport{transport: http.DefaultTransport} + + resp, err := retryableTransport.RoundTrip(req) + if err != nil { + t.Fatalf("Expected nil error, got %v", err) + } + if resp.StatusCode != http.StatusOK { + t.Fatalf("Expected status code %v, got %v", http.StatusOK, resp.StatusCode) + } + if httpmock.GetTotalCallCount() != 1 { + t.Fatalf("Expected 1 call, got %v", httpmock.GetTotalCallCount()) + } +} + +func TestRoundTripRetriesWithBody(t *testing.T) { + _ = os.Setenv(testRetryCountEnvVariableKey, fmt.Sprintf("%v", testRetryCount)) + defer func() { + _ = os.Unsetenv(testRetryCountEnvVariableKey) + }() + httpmock.Activate() + defer httpmock.DeactivateAndReset() + + httpmock.RegisterResponder(testPostHttpMethod, testUrl, + httpmock.NewErrorResponder(fmt.Errorf("network error"))) + + body := "test body" + req, _ := http.NewRequest(testPostHttpMethod, testUrl, bytes.NewBufferString(body)) + retryableTransport := &RetryableTransport{transport: http.DefaultTransport} + + resp, err := retryableTransport.RoundTrip(req) + if err == nil { + t.Fatalf("Expected error, got nil") + } + if resp != nil { + t.Fatalf("Expected nil response, got %v", resp) + } + if httpmock.GetTotalCallCount() != testRetryCount+1 { + t.Fatalf("Expected %v retries, got %v", testRetryCount, httpmock.GetTotalCallCount()-1) + } + if req.Body == nil { + t.Fatalf("Expected request body to be retried, but it was not") + } +} + +func TestDrainBodyWithNilResponse(t *testing.T) { + drainBody(nil) + // No assertions needed, just ensure no panic occurs +} + +func TestDrainBodyWithNilBody(t *testing.T) { + resp := &http.Response{} + drainBody(resp) + // No assertions needed, just ensure no panic occurs +} + +func TestDrainBodyWithNonNilBody(t *testing.T) { + mockBody := &mockReadCloser{} + resp := &http.Response{ + Body: mockBody, + } + drainBody(resp) + + if !mockBody.closed { + t.Fatalf("Expected body to be closed, but it was not") + } +} diff --git a/cdp-sdk-go/cdp/transport.go b/cdp-sdk-go/cdp/transport.go index 93286141..2fc7c444 100644 --- a/cdp-sdk-go/cdp/transport.go +++ b/cdp-sdk-go/cdp/transport.go @@ -19,12 +19,41 @@ import ( "github.com/go-openapi/runtime/client" ) -var prefixTrim = []string{"http://", "https://"} +var ( + prefixTrim = []string{"http://", "https://"} + retryableStatusCodes = []int{ + http.StatusServiceUnavailable, + http.StatusTooManyRequests, + http.StatusGatewayTimeout, + http.StatusBadGateway, + http.StatusPreconditionFailed, + } +) + +const ( + defaultLinearBackoffStep = 2 +) type ClientTransport struct { Runtime *client.Runtime } +type DelegatingRoundTripper struct { + delegate http.RoundTripper +} + +type LoggingRoundTripper struct { + DelegatingRoundTripper + logger Logger +} + +// RequestHeadersRoundTripper sets the User-Agent and other custom headers +// see https://github.com/go-swagger/go-swagger/blob/701e7f3ee85df9d47fcf639dd7a279f7ab6d94d7/docs/faq/faq_client.md?plain=1#L28 +type RequestHeadersRoundTripper struct { + DelegatingRoundTripper + headers map[string]string +} + func (t *ClientTransport) Submit(operation *runtime.ClientOperation) (interface{}, error) { response, err := t.Runtime.Submit(operation) return response, err @@ -39,7 +68,11 @@ func getDefaultTransport(config *Config) (http.RoundTripper, error) { return nil, err } - return &http.Transport{Proxy: http.ProxyFromEnvironment, TLSClientConfig: cfg}, nil + retryableTransport := &RetryableTransport{ + transport: &http.Transport{Proxy: http.ProxyFromEnvironment, TLSClientConfig: cfg}, + } + + return retryableTransport, nil } func buildClientTransportWithDefaultHttpTransport(config *Config, endpoint string) (*ClientTransport, error) { @@ -106,15 +139,6 @@ func buildRequestHeadersRoundTripper(config *Config, delegate http.RoundTripper) return reqHeadersRT } -type DelegatingRoundTripper struct { - delegate http.RoundTripper -} - -type LoggingRoundTripper struct { - DelegatingRoundTripper - logger Logger -} - func (t *LoggingRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { startTime := time.Now() resp, err := t.delegate.RoundTrip(req) @@ -129,13 +153,6 @@ func (t *LoggingRoundTripper) RoundTrip(req *http.Request) (*http.Response, erro return resp, err } -// RequestHeadersRoundTripper sets the User-Agent and other custom headers -// see https://github.com/go-swagger/go-swagger/blob/701e7f3ee85df9d47fcf639dd7a279f7ab6d94d7/docs/faq/faq_client.md?plain=1#L28 -type RequestHeadersRoundTripper struct { - DelegatingRoundTripper - headers map[string]string -} - func (r *RequestHeadersRoundTripper) AddHeader(key, value string) { if key == "" || value == "" { return diff --git a/cdp-sdk-go/cdp/transport_test.go b/cdp-sdk-go/cdp/transport_test.go index 8fc5ba3d..b1e792fd 100644 --- a/cdp-sdk-go/cdp/transport_test.go +++ b/cdp-sdk-go/cdp/transport_test.go @@ -12,21 +12,27 @@ package cdp import ( "context" - "net/http" - "regexp" - "testing" - "github.com/go-openapi/errors" "github.com/go-openapi/runtime" "github.com/go-openapi/strfmt" + "net/http" + "regexp" + "testing" ) +const retryUrl = "http://example.com/" + var ( testCredentials = Credentials{ AccessKeyId: "auth_test", PrivateKey: "37yMdtdkJANPn62X5KDKKI3iv5hbAAKvqxHdgIj22bo=", } testEndpoint = "https://api.us-west-1.cdp.cloudera.com" + retryClient = &http.Client{ + Transport: &RetryableTransport{ + transport: &http.Transport{}, + }, + } ) type mockRoundTripper struct { @@ -35,7 +41,7 @@ type mockRoundTripper struct { func (t *mockRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { t.req = req - return nil, errors.NotImplemented("mock") + return nil, errors.New(http.StatusTooManyRequests, "mock") } func getTestOperation() *runtime.ClientOperation { diff --git a/cdp-sdk-go/cdp/utils.go b/cdp-sdk-go/cdp/utils.go new file mode 100644 index 00000000..434a4da2 --- /dev/null +++ b/cdp-sdk-go/cdp/utils.go @@ -0,0 +1,37 @@ +// Copyright 2024 Cloudera. All Rights Reserved. +// +// This file is licensed under the Apache License Version 2.0 (the "License"). +// You may not use this file except in compliance with the License. +// You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0. +// +// This file is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS +// OF ANY KIND, either express or implied. Refer to the License for the specific +// permissions and limitations governing your use of the file. + +package cdp + +import ( + "os" + "strconv" +) + +func sliceContains(slice []int, element int) bool { + for _, e := range slice { + if e == element { + return true + } + } + return false +} + +func intFromEnvOrDefault(env string, fallback int) int { + value := os.Getenv(env) + if value == "" { + return fallback + } + i, err := strconv.Atoi(value) + if err != nil { + return fallback + } + return i +} diff --git a/cdp-sdk-go/cdp/utils_test.go b/cdp-sdk-go/cdp/utils_test.go new file mode 100644 index 00000000..078f0628 --- /dev/null +++ b/cdp-sdk-go/cdp/utils_test.go @@ -0,0 +1,126 @@ +// Copyright 2024 Cloudera. All Rights Reserved. +// +// This file is licensed under the Apache License Version 2.0 (the "License"). +// You may not use this file except in compliance with the License. +// You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0. +// +// This file is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS +// OF ANY KIND, either express or implied. Refer to the License for the specific +// permissions and limitations governing your use of the file. + +package cdp + +import ( + "os" + "testing" +) + +func TestContainsInt(t *testing.T) { + type input struct { + description string + content []int + target int + shouldContain bool + } + for _, scenario := range []input{ + { + description: "input is empty slice", + content: []int{}, + target: 1, + shouldContain: false, + }, + { + description: "input is not empty but does not contain target", + content: []int{1, 2}, + target: 3, + shouldContain: false, + }, + { + description: "input is not empty and contains target", + content: []int{1, 2, 3}, + target: 3, + shouldContain: true, + }, + } { + t.Run(scenario.description, func(t *testing.T) { + if result := ContainsInt(scenario.content, scenario.target); result != scenario.shouldContain { + t.Errorf("The expected '%t' value does not match with the result: '%t'.", scenario.shouldContain, result) + } + }) + } +} + +func ContainsInt(slice []int, element int) bool { + for _, e := range slice { + if e == element { + return true + } + } + return false +} + +func TestEnvironmentVariableIsSet(t *testing.T) { + _ = os.Setenv("TEST_ENV_VAR", "42") + defer func() { + _ = os.Unsetenv("TEST_ENV_VAR") + }() + + result := intFromEnvOrDefault("TEST_ENV_VAR", 10) + expected := 42 + if result != expected { + t.Fatalf("Expected %v, got %v", expected, result) + } +} + +func TestEnvironmentVariableIsNotSet(t *testing.T) { + _ = os.Unsetenv("TEST_ENV_VAR") + + result := intFromEnvOrDefault("TEST_ENV_VAR", 10) + expected := 10 + if result != expected { + t.Fatalf("Expected %v, got %v", expected, result) + } +} + +func TestEnvironmentVariableIsInvalid(t *testing.T) { + _ = os.Setenv("TEST_ENV_VAR", "invalid") + defer func() { + _ = os.Unsetenv("TEST_ENV_VAR") + }() + + result := intFromEnvOrDefault("TEST_ENV_VAR", 10) + expected := 10 + if result != expected { + t.Fatalf("Expected %v, got %v", expected, result) + } +} + +func TestSliceContainsElement(t *testing.T) { + slice := []int{1, 2, 3} + element := 2 + + result := sliceContains(slice, element) + if result != true { + t.Fatalf("Expected %v, got %v", true, result) + } +} + +func TestSliceDoesNotContainElement(t *testing.T) { + slice := []int{1, 2, 3} + element := 4 + + result := sliceContains(slice, element) + if result != false { + t.Fatalf("Expected %v, got %v", false, result) + } +} + +func TestSliceIsEmpty(t *testing.T) { + var slice []int + element := 1 + + result := sliceContains(slice, element) + if result != false { + t.Fatalf("Expected %v, got %v", false, result) + } +} diff --git a/go.mod b/go.mod index 8435bb4f..72f30782 100644 --- a/go.mod +++ b/go.mod @@ -75,6 +75,7 @@ require ( github.com/hashicorp/yamux v0.1.1 // indirect github.com/huandu/xstrings v1.4.0 // indirect github.com/imdario/mergo v0.3.16 // indirect + github.com/jarcoal/httpmock v1.3.1 // indirect github.com/jessevdk/go-flags v1.5.0 // indirect github.com/josharian/intern v1.0.0 // indirect github.com/kr/pretty v0.3.1 // indirect diff --git a/go.sum b/go.sum index d2fbcb81..967f78ca 100644 --- a/go.sum +++ b/go.sum @@ -166,6 +166,8 @@ github.com/huandu/xstrings v1.4.0/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq github.com/imdario/mergo v0.3.11/go.mod h1:jmQim1M+e3UYxmgPu/WyfjB3N3VflVyUjjjwH0dnCYA= github.com/imdario/mergo v0.3.16 h1:wwQJbIsHYGMUyLSPrEq1CT16AhnhNJQ51+4fdHUnCl4= github.com/imdario/mergo v0.3.16/go.mod h1:WBLT9ZmE3lPoWsEzCh9LPo3TiwVN+ZKEjmz+hD27ysY= +github.com/jarcoal/httpmock v1.3.1 h1:iUx3whfZWVf3jT01hQTO/Eo5sAYtB2/rqaUuOtpInww= +github.com/jarcoal/httpmock v1.3.1/go.mod h1:3yb8rc4BI7TCBhFY8ng0gjuLKJNquuDNiPaZjnENuYg= github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A= github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo= github.com/jessevdk/go-flags v1.5.0 h1:1jKYvbxEjfUl0fmqTCOfonvskHHXMjBySTLW4y9LFvc=