diff --git a/alioss/README.md b/alioss/README.md index a33f39ff..c29a1c30 100644 --- a/alioss/README.md +++ b/alioss/README.md @@ -16,7 +16,8 @@ The AliOSS client requires a JSON configuration file with the following structur "access_key_secret": " (required)", "endpoint": " (required)", "bucket_name": " (required)", - "http_request_timeout": " (optional)" + "http_request_timeout": " (optional)", + "http_response_header_timeout": " (optional)" } ``` diff --git a/alioss/client/storage_client.go b/alioss/client/storage_client.go index 9a942847..33834f14 100644 --- a/alioss/client/storage_client.go +++ b/alioss/client/storage_client.go @@ -94,11 +94,22 @@ func NewStorageClient(storageConfig config.AliStorageConfig) (StorageClient, err }, nil } -func newOSSClient(endpoint, accessKeyID, accessKeySecret string, httpRequestTimeoutSeconds int64) (*oss.Client, error) { +func setOSSResponseHeaderTimeout(timeout time.Duration) oss.ClientOption { + return func(client *oss.Client) { + if client.Config != nil { + client.Config.HTTPTimeout.HeaderTimeout = timeout + } + } +} + +func newOSSClient(endpoint, accessKeyID, accessKeySecret string, httpRequestTimeoutSeconds int64, httpResponseHeaderTimeout time.Duration) (*oss.Client, error) { options := make([]oss.ClientOption, 0, 3) if httpRequestTimeoutSeconds > 0 { options = append(options, oss.Timeout(httpRequestTimeoutSeconds, httpRequestTimeoutSeconds)) } + if httpResponseHeaderTimeout > 0 { + options = append(options, setOSSResponseHeaderTimeout(httpResponseHeaderTimeout)) + } if common.IsDebug() { slogLogger := slog.Default() @@ -115,11 +126,17 @@ func (dsc DefaultStorageClient) newOSSClient() (*oss.Client, error) { return nil, err } + httpResponseHeaderTimeout, err := dsc.storageConfig.HTTPResponseHeaderTimeoutDuration() + if err != nil { + return nil, err + } + return newOSSClient( dsc.storageConfig.Endpoint, dsc.storageConfig.AccessKeyID, dsc.storageConfig.AccessKeySecret, httpRequestTimeoutSeconds, + httpResponseHeaderTimeout, ) } diff --git a/alioss/config/config.go b/alioss/config/config.go index 773a08ca..727f4651 100644 --- a/alioss/config/config.go +++ b/alioss/config/config.go @@ -9,14 +9,16 @@ import ( ) type AliStorageConfig struct { - AccessKeyID string `json:"access_key_id"` - AccessKeySecret string `json:"access_key_secret"` - Endpoint string `json:"endpoint"` - BucketName string `json:"bucket_name"` - HTTPRequestTimeout string `json:"http_request_timeout"` + AccessKeyID string `json:"access_key_id"` + AccessKeySecret string `json:"access_key_secret"` + Endpoint string `json:"endpoint"` + BucketName string `json:"bucket_name"` + HTTPRequestTimeout string `json:"http_request_timeout"` + HTTPResponseHeaderTimeout string `json:"http_response_header_timeout"` } var errorNonPositiveHTTPRequestTimeout = errors.New("http_request_timeout must be greater than 0") +var errorNonPositiveHTTPResponseHeaderTimeout = errors.New("http_response_header_timeout must be greater than 0") // NewFromReader returns a new ali-storage-cli configuration struct from the contents of reader. // reader.Read() is expected to return valid JSON @@ -35,6 +37,9 @@ func NewFromReader(reader io.Reader) (AliStorageConfig, error) { if _, err := config.HTTPRequestTimeoutSeconds(); err != nil { return AliStorageConfig{}, err } + if _, err := config.HTTPResponseHeaderTimeoutDuration(); err != nil { + return AliStorageConfig{}, err + } return config, nil } @@ -61,3 +66,20 @@ func (c AliStorageConfig) HTTPRequestTimeoutSeconds() (int64, error) { return timeoutSeconds, nil } + +func (c AliStorageConfig) HTTPResponseHeaderTimeoutDuration() (time.Duration, error) { + if c.HTTPResponseHeaderTimeout == "" { + return 0, nil + } + + httpResponseHeaderTimeout, err := time.ParseDuration(c.HTTPResponseHeaderTimeout) + if err != nil { + return 0, fmt.Errorf("invalid http_response_header_timeout: %w", err) + } + + if httpResponseHeaderTimeout <= 0 { + return 0, errorNonPositiveHTTPResponseHeaderTimeout + } + + return httpResponseHeaderTimeout, nil +} diff --git a/alioss/config/config_test.go b/alioss/config/config_test.go index 166410aa..b69e2e5f 100644 --- a/alioss/config/config_test.go +++ b/alioss/config/config_test.go @@ -16,7 +16,8 @@ var _ = Describe("Config", func() { "access_key_secret": "foo_access_key_secret", "endpoint": "foo_endpoint", "bucket_name": "foo_bucket_name", - "http_request_timeout": "30s"}`) + "http_request_timeout": "30s", + "http_response_header_timeout": "5s"}`) configReader := bytes.NewReader(configJson) config, err := config.NewFromReader(configReader) @@ -27,10 +28,14 @@ var _ = Describe("Config", func() { Expect(config.Endpoint).To(Equal("foo_endpoint")) Expect(config.BucketName).To(Equal("foo_bucket_name")) Expect(config.HTTPRequestTimeout).To(Equal("30s")) + Expect(config.HTTPResponseHeaderTimeout).To(Equal("5s")) timeoutSeconds, err := config.HTTPRequestTimeoutSeconds() Expect(err).ToNot(HaveOccurred()) Expect(timeoutSeconds).To(Equal(int64(30))) + headerTimeout, err := config.HTTPResponseHeaderTimeoutDuration() + Expect(err).ToNot(HaveOccurred()) + Expect(headerTimeout.Seconds()).To(Equal(5.0)) }) It("rounds up sub-second timeout in HTTPRequestTimeoutSeconds getter", func() { @@ -65,6 +70,22 @@ var _ = Describe("Config", func() { Expect(timeoutSeconds).To(BeZero()) }) + It("leaves response header timeout unset when http_response_header_timeout is not provided", func() { + configJson := []byte(`{"access_key_id": "foo_access_key_id", + "access_key_secret": "foo_access_key_secret", + "endpoint": "foo_endpoint", + "bucket_name": "foo_bucket_name"}`) + configReader := bytes.NewReader(configJson) + + config, err := config.NewFromReader(configReader) + + Expect(err).ToNot(HaveOccurred()) + Expect(config.HTTPResponseHeaderTimeout).To(BeEmpty()) + headerTimeout, err := config.HTTPResponseHeaderTimeoutDuration() + Expect(err).ToNot(HaveOccurred()) + Expect(headerTimeout).To(BeZero()) + }) + It("returns an error when http_request_timeout has invalid format", func() { configJson := []byte(`{"access_key_id": "foo_access_key_id", "access_key_secret": "foo_access_key_secret", @@ -92,6 +113,33 @@ var _ = Describe("Config", func() { Expect(err).To(MatchError("http_request_timeout must be greater than 0")) }) + It("returns an error when http_response_header_timeout has invalid format", func() { + configJson := []byte(`{"access_key_id": "foo_access_key_id", + "access_key_secret": "foo_access_key_secret", + "endpoint": "foo_endpoint", + "bucket_name": "foo_bucket_name", + "http_response_header_timeout": "bananas"}`) + configReader := bytes.NewReader(configJson) + + _, err := config.NewFromReader(configReader) + + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("invalid http_response_header_timeout")) + }) + + It("returns an error when http_response_header_timeout is non-positive", func() { + configJson := []byte(`{"access_key_id": "foo_access_key_id", + "access_key_secret": "foo_access_key_secret", + "endpoint": "foo_endpoint", + "bucket_name": "foo_bucket_name", + "http_response_header_timeout": "0s"}`) + configReader := bytes.NewReader(configJson) + + _, err := config.NewFromReader(configReader) + + Expect(err).To(MatchError("http_response_header_timeout must be greater than 0")) + }) + It("is empty if config cannot be parsed", func() { configJson := []byte(`~`) configReader := bytes.NewReader(configJson) diff --git a/gcs/README.md b/gcs/README.md index 7746fe4b..bb144b67 100644 --- a/gcs/README.md +++ b/gcs/README.md @@ -18,6 +18,7 @@ The GCS client requires a JSON configuration file. "json_key": " (required if credentials_source = 'static')", "storage_class": " (optional - default: 'STANDARD', check for more options=https://docs.cloud.google.com/storage/docs/storage-classes)", "http_request_timeout": " (optional)", + "http_response_header_timeout": " (optional)", "encryption_key": " (optional)", "uniform_bucket_level_access": " (optional)" } diff --git a/gcs/client/sdk.go b/gcs/client/sdk.go index dd14c8cf..ddc93ddd 100644 --- a/gcs/client/sdk.go +++ b/gcs/client/sdk.go @@ -21,6 +21,8 @@ import ( "encoding/json" "errors" "fmt" + "net/http" + "time" "golang.org/x/oauth2" "golang.org/x/oauth2/google" @@ -28,8 +30,6 @@ import ( "google.golang.org/api/option" - "net/http" - "cloud.google.com/go/storage" "github.com/cloudfoundry/storage-cli/common" "github.com/cloudfoundry/storage-cli/gcs/client/middleware" @@ -38,15 +38,40 @@ import ( const uaString = "storage-cli-gcs" +func withResponseHeaderTimeout(base http.RoundTripper, timeout time.Duration) http.RoundTripper { + if timeout == 0 { + return base + } + + if base == nil { + base = http.DefaultTransport + } + + transport, ok := base.(*http.Transport) + if !ok { + return base + } + + cloned := transport.Clone() + cloned.ResponseHeaderTimeout = timeout + return cloned +} + func newStorageClients(ctx context.Context, cfg *config.GCSCli) (*storage.Client, *storage.Client, error) { requestTimeout, err := cfg.HTTPRequestTimeoutValue() if err != nil { return nil, nil, err } + responseHeaderTimeout, err := cfg.HTTPResponseHeaderTimeoutDuration() + if err != nil { + return nil, nil, err + } + publicHTTPClient := &http.Client{Timeout: requestTimeout} + publicHTTPClient.Transport = withResponseHeaderTimeout(publicHTTPClient.Transport, responseHeaderTimeout) if common.IsDebug() { - publicHTTPClient.Transport = middleware.NewLoggingTransport(http.DefaultTransport) + publicHTTPClient.Transport = middleware.NewLoggingTransport(publicHTTPClient.Transport) } publicClient, err := storage.NewClient(ctx, option.WithUserAgent(uaString), option.WithHTTPClient(publicHTTPClient)) @@ -60,6 +85,7 @@ func newStorageClients(ctx context.Context, cfg *config.GCSCli) (*storage.Client case config.DefaultCredentialsSource: if tokenSource, err = google.DefaultTokenSource(ctx, storage.ScopeFullControl); err == nil { baseClient := oauth2.NewClient(ctx, tokenSource) + baseClient.Transport = withResponseHeaderTimeout(baseClient.Transport, responseHeaderTimeout) if common.IsDebug() { baseClient.Transport = middleware.NewLoggingTransport(baseClient.Transport) } @@ -70,6 +96,7 @@ func newStorageClients(ctx context.Context, cfg *config.GCSCli) (*storage.Client if token, err = google.JWTConfigFromJSON([]byte(cfg.ServiceAccountFile), storage.ScopeFullControl); err == nil { tokenSource := token.TokenSource(ctx) baseClient := oauth2.NewClient(ctx, tokenSource) + baseClient.Transport = withResponseHeaderTimeout(baseClient.Transport, responseHeaderTimeout) if common.IsDebug() { baseClient.Transport = middleware.NewLoggingTransport(baseClient.Transport) } diff --git a/gcs/config/config.go b/gcs/config/config.go index 0594780e..8c76370d 100644 --- a/gcs/config/config.go +++ b/gcs/config/config.go @@ -55,6 +55,9 @@ type GCSCli struct { // HTTPRequestTimeout specifies the maximum duration for each GCS HTTP request. // If empty, requests have no client-side timeout. HTTPRequestTimeout string `json:"http_request_timeout"` + // HTTPResponseHeaderTimeout specifies how long to wait for response headers. + // If empty, no explicit response header timeout is configured. + HTTPResponseHeaderTimeout string `json:"http_response_header_timeout"` EncryptionKeyEncoded string EncryptionKeySha256 string @@ -87,12 +90,14 @@ var ErrWrongLengthEncryptionKey = errors.New("encryption_key not 32 bytes") // ErrNonPositiveHTTPRequestTimeout is returned when http_request_timeout is <= 0. var ErrNonPositiveHTTPRequestTimeout = errors.New("http_request_timeout must be greater than 0") +// ErrNonPositiveHTTPResponseHeaderTimeout is returned when http_response_header_timeout is <= 0. +var ErrNonPositiveHTTPResponseHeaderTimeout = errors.New("http_response_header_timeout must be greater than 0") + // NewFromReader returns the new gcscli configuration struct from the // contents of the reader. // // reader.Read() is expected to return valid JSON. func NewFromReader(reader io.Reader) (GCSCli, error) { - dec := json.NewDecoder(reader) var c GCSCli if err := dec.Decode(&c); err != nil { @@ -103,8 +108,7 @@ func NewFromReader(reader io.Reader) (GCSCli, error) { return GCSCli{}, ErrEmptyBucketName } - if c.CredentialsSource == ServiceAccountFileCredentialsSource && - c.ServiceAccountFile == "" { + if c.CredentialsSource == ServiceAccountFileCredentialsSource && c.ServiceAccountFile == "" { return GCSCli{}, ErrEmptyServiceAccountFile } @@ -124,22 +128,34 @@ func NewFromReader(reader io.Reader) (GCSCli, error) { return GCSCli{}, err } + if _, err := c.HTTPResponseHeaderTimeoutDuration(); err != nil { + return GCSCli{}, err + } + return c, nil } func (c *GCSCli) HTTPRequestTimeoutValue() (time.Duration, error) { - if c.HTTPRequestTimeout == "" { + return parseOptionalPositiveDuration("http_request_timeout", c.HTTPRequestTimeout, ErrNonPositiveHTTPRequestTimeout) +} + +func (c *GCSCli) HTTPResponseHeaderTimeoutDuration() (time.Duration, error) { + return parseOptionalPositiveDuration("http_response_header_timeout", c.HTTPResponseHeaderTimeout, ErrNonPositiveHTTPResponseHeaderTimeout) +} + +func parseOptionalPositiveDuration(fieldName, value string, nonPositiveErr error) (time.Duration, error) { + if value == "" { return 0, nil } - requestTimeout, err := time.ParseDuration(c.HTTPRequestTimeout) + parsedDuration, err := time.ParseDuration(value) if err != nil { - return 0, fmt.Errorf("invalid http_request_timeout: %w", err) + return 0, fmt.Errorf("invalid %s: %w", fieldName, err) } - if requestTimeout <= 0 { - return 0, ErrNonPositiveHTTPRequestTimeout + if parsedDuration <= 0 { + return 0, nonPositiveErr } - return requestTimeout, nil + return parsedDuration, nil } diff --git a/gcs/config/config_test.go b/gcs/config/config_test.go index 372106f5..3647f455 100644 --- a/gcs/config/config_test.go +++ b/gcs/config/config_test.go @@ -218,4 +218,53 @@ var _ = Describe("BlobstoreClient configuration", func() { }) }) + Describe("when http_response_header_timeout is set", func() { + dummyJSONBytes := []byte(`{"bucket_name": "some-bucket", "http_response_header_timeout":"5s"}`) + dummyJSONReader := bytes.NewReader(dummyJSONBytes) + + It("parses and stores timeout", func() { + c, err := NewFromReader(dummyJSONReader) + Expect(err).To(BeNil()) + Expect(c.HTTPResponseHeaderTimeout).To(Equal("5s")) + timeoutValue, err := c.HTTPResponseHeaderTimeoutDuration() + Expect(err).To(BeNil()) + Expect(timeoutValue.Seconds()).To(Equal(5.0)) + }) + }) + + Describe("when http_response_header_timeout is not set", func() { + dummyJSONBytes := []byte(`{"bucket_name": "some-bucket"}`) + dummyJSONReader := bytes.NewReader(dummyJSONBytes) + + It("leaves timeout unset", func() { + c, err := NewFromReader(dummyJSONReader) + Expect(err).To(BeNil()) + Expect(c.HTTPResponseHeaderTimeout).To(BeEmpty()) + timeoutValue, err := c.HTTPResponseHeaderTimeoutDuration() + Expect(err).To(BeNil()) + Expect(timeoutValue).To(BeZero()) + }) + }) + + Describe("when http_response_header_timeout has invalid format", func() { + dummyJSONBytes := []byte(`{"bucket_name": "some-bucket", "http_response_header_timeout":"bananas"}`) + dummyJSONReader := bytes.NewReader(dummyJSONBytes) + + It("returns an error", func() { + _, err := NewFromReader(dummyJSONReader) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("invalid http_response_header_timeout")) + }) + }) + + Describe("when http_response_header_timeout is non-positive", func() { + dummyJSONBytes := []byte(`{"bucket_name": "some-bucket", "http_response_header_timeout":"0s"}`) + dummyJSONReader := bytes.NewReader(dummyJSONBytes) + + It("returns an error", func() { + _, err := NewFromReader(dummyJSONReader) + Expect(err).To(MatchError(ErrNonPositiveHTTPResponseHeaderTimeout)) + }) + }) + }) diff --git a/s3/README.md b/s3/README.md index c006ebc9..59cd066a 100644 --- a/s3/README.md +++ b/s3/README.md @@ -22,6 +22,7 @@ The S3 client requires a JSON configuration file with the following structure: "ssl_verify_peer": (optional - default: true), "use_ssl": (optional - default: true), "http_request_timeout": " (optional)", + "http_response_header_timeout": " (optional)", "signature_version": " (optional)", "server_side_encryption": " (optional)", "sse_kms_key_id": " (optional)", @@ -39,6 +40,7 @@ The S3 client requires a JSON configuration file with the following structure: ``` If `http_request_timeout` is omitted, the HTTP client timeout is left unset. +If `http_response_header_timeout` is omitted, the transport response-header timeout is left unset. **Usage examples:** ```shell diff --git a/s3/client/sdk.go b/s3/client/sdk.go index c2b97324..cb3389b0 100644 --- a/s3/client/sdk.go +++ b/s3/client/sdk.go @@ -4,6 +4,7 @@ import ( "context" "net/http" "strings" + "time" "github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/config" @@ -19,6 +20,25 @@ import ( s3cli_config "github.com/cloudfoundry/storage-cli/s3/config" ) +func withResponseHeaderTimeout(base http.RoundTripper, timeout time.Duration) http.RoundTripper { + if timeout == 0 { + return base + } + + if base == nil { + base = http.DefaultTransport + } + + transport, ok := base.(*http.Transport) + if !ok { + return base + } + + cloned := transport.Clone() + cloned.ResponseHeaderTimeout = timeout + return cloned +} + func NewAwsS3Client(c *s3cli_config.S3Cli) (*s3.Client, error) { var apiOptions []func(stack *middleware.Stack) error if c.IsGoogle() { @@ -42,16 +62,22 @@ func NewAwsS3ClientWithApiOptions( httpClient = boshhttp.CreateDefaultClientInsecureSkipVerify() } - if common.IsDebug() { - httpClient.Transport = s3middleware.NewS3LoggingTransport(httpClient.Transport) - } - httpRequestTimeout, err := c.HTTPRequestTimeoutValue() if err != nil { return nil, err } httpClient.Timeout = httpRequestTimeout + httpResponseHeaderTimeout, err := c.HTTPResponseHeaderTimeoutDuration() + if err != nil { + return nil, err + } + httpClient.Transport = withResponseHeaderTimeout(httpClient.Transport, httpResponseHeaderTimeout) + + if common.IsDebug() { + httpClient.Transport = s3middleware.NewS3LoggingTransport(httpClient.Transport) + } + options := []func(*config.LoadOptions) error{ config.WithHTTPClient(httpClient), } diff --git a/s3/config/config.go b/s3/config/config.go index 0a74c38c..fc588b92 100644 --- a/s3/config/config.go +++ b/s3/config/config.go @@ -24,6 +24,7 @@ type S3Cli struct { SSLVerifyPeer bool `json:"ssl_verify_peer"` UseSSL bool `json:"use_ssl"` HTTPRequestTimeout string `json:"http_request_timeout"` + HTTPResponseHeaderTimeout string `json:"http_response_header_timeout"` ServerSideEncryption string `json:"server_side_encryption"` SSEKMSKeyID string `json:"sse_kms_key_id"` AssumeRoleArn string `json:"assume_role_arn"` @@ -74,6 +75,7 @@ const noCredentialsSourceProvided = "" var errorStaticCredentialsMissing = errors.New("access_key_id and secret_access_key must be provided") var errorNonPositiveHTTPRequestTimeout = errors.New("http_request_timeout must be greater than 0") +var errorNonPositiveHTTPResponseHeaderTimeout = errors.New("http_response_header_timeout must be greater than 0") type errorStaticCredentialsPresent struct { credentialsSource string @@ -139,6 +141,9 @@ func NewFromReader(reader io.Reader) (S3Cli, error) { if _, err := c.HTTPRequestTimeoutValue(); err != nil { return S3Cli{}, err } + if _, err := c.HTTPResponseHeaderTimeoutDuration(); err != nil { + return S3Cli{}, err + } switch c.CredentialsSource { case StaticCredentialsSource: @@ -264,22 +269,30 @@ func (c *S3Cli) ShouldDisableUploaderRequestChecksumCalculation() bool { } func (c *S3Cli) HTTPRequestTimeoutValue() (time.Duration, error) { - if c.HTTPRequestTimeout == "" { + return parseOptionalPositiveDuration("http_request_timeout", c.HTTPRequestTimeout, errorNonPositiveHTTPRequestTimeout) +} + +func (c *S3Cli) HTTPResponseHeaderTimeoutDuration() (time.Duration, error) { + return parseOptionalPositiveDuration("http_response_header_timeout", c.HTTPResponseHeaderTimeout, errorNonPositiveHTTPResponseHeaderTimeout) +} + +func parseOptionalPositiveDuration(fieldName, value string, nonPositiveErr error) (time.Duration, error) { + if value == "" { return 0, nil } - if _, err := strconv.ParseFloat(c.HTTPRequestTimeout, 64); err == nil { - return 0, fmt.Errorf("invalid http_request_timeout: missing duration unit") + if _, err := strconv.ParseFloat(value, 64); err == nil { + return 0, fmt.Errorf("invalid %s: missing duration unit", fieldName) } - httpRequestTimeout, err := time.ParseDuration(c.HTTPRequestTimeout) + parsedDuration, err := time.ParseDuration(value) if err != nil { - return 0, fmt.Errorf("invalid http_request_timeout: %w", err) + return 0, fmt.Errorf("invalid %s: %w", fieldName, err) } - if httpRequestTimeout <= 0 { - return 0, errorNonPositiveHTTPRequestTimeout + if parsedDuration <= 0 { + return 0, nonPositiveErr } - return httpRequestTimeout, nil + return parsedDuration, nil } diff --git a/s3/config/config_test.go b/s3/config/config_test.go index f1765c29..9d7769de 100644 --- a/s3/config/config_test.go +++ b/s3/config/config_test.go @@ -456,6 +456,66 @@ var _ = Describe("BlobstoreClient configuration", func() { }) }) + Describe("http_response_header_timeout", func() { + It("leaves timeout unset when not set", func() { + dummyJSONBytes := []byte(`{"access_key_id":"id","secret_access_key":"key","bucket_name":"some-bucket"}`) + dummyJSONReader := bytes.NewReader(dummyJSONBytes) + + c, err := config.NewFromReader(dummyJSONReader) + Expect(err).ToNot(HaveOccurred()) + Expect(c.HTTPResponseHeaderTimeout).To(BeEmpty()) + timeout, err := c.HTTPResponseHeaderTimeoutDuration() + Expect(err).ToNot(HaveOccurred()) + Expect(timeout).To(BeZero()) + }) + + It("parses a valid duration", func() { + dummyJSONBytes := []byte(`{"access_key_id":"id","secret_access_key":"key","bucket_name":"some-bucket","http_response_header_timeout":"3s"}`) + dummyJSONReader := bytes.NewReader(dummyJSONBytes) + + c, err := config.NewFromReader(dummyJSONReader) + Expect(err).ToNot(HaveOccurred()) + Expect(c.HTTPResponseHeaderTimeout).To(Equal("3s")) + timeout, err := c.HTTPResponseHeaderTimeoutDuration() + Expect(err).ToNot(HaveOccurred()) + Expect(timeout.Seconds()).To(Equal(3.0)) + }) + + It("rejects numeric timeout values", func() { + dummyJSONBytes := []byte(`{"access_key_id":"id","secret_access_key":"key","bucket_name":"some-bucket","http_response_header_timeout":45}`) + dummyJSONReader := bytes.NewReader(dummyJSONBytes) + + _, err := config.NewFromReader(dummyJSONReader) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("cannot unmarshal number into Go struct field")) + }) + + It("rejects invalid duration formats", func() { + dummyJSONBytes := []byte(`{"access_key_id":"id","secret_access_key":"key","bucket_name":"some-bucket","http_response_header_timeout":"bananas"}`) + dummyJSONReader := bytes.NewReader(dummyJSONBytes) + + _, err := config.NewFromReader(dummyJSONReader) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("invalid http_response_header_timeout")) + }) + + It("rejects negative durations", func() { + dummyJSONBytes := []byte(`{"access_key_id":"id","secret_access_key":"key","bucket_name":"some-bucket","http_response_header_timeout":"-1s"}`) + dummyJSONReader := bytes.NewReader(dummyJSONBytes) + + _, err := config.NewFromReader(dummyJSONReader) + Expect(err).To(MatchError("http_response_header_timeout must be greater than 0")) + }) + + It("rejects zero durations", func() { + dummyJSONBytes := []byte(`{"access_key_id":"id","secret_access_key":"key","bucket_name":"some-bucket","http_response_header_timeout":"0s"}`) + dummyJSONReader := bytes.NewReader(dummyJSONBytes) + + _, err := config.NewFromReader(dummyJSONReader) + Expect(err).To(MatchError("http_response_header_timeout must be greater than 0")) + }) + }) + Describe("returning the S3 endpoint", func() { Context("when port is provided", func() { It("returns a URI in the form `host:port`", func() {