Skip to content

Commit 448b523

Browse files
committed
Configurable http client timeout for Azure Blob Storage
1 parent 2e99f46 commit 448b523

4 files changed

Lines changed: 144 additions & 23 deletions

File tree

azurebs/README.md

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,14 @@ The Azure client requires a JSON configuration file with the following structure
1515
"account_name": "<string> (required)",
1616
"account_key": "<string> (required)",
1717
"container_name": "<string> (required)",
18-
"environment": "<string> (optional, default: 'AzureCloud')"
18+
"environment": "<string> (optional, default: 'AzureCloud')",
19+
"put_timeout_in_seconds": "<string> (optional, e.g. '30', default: no timeout)",
20+
"http_request_timeout": "<string> (optional, Go duration e.g. '30s', default: no timeout)"
1921
}
2022
```
2123

24+
`put_timeout_in_seconds` sets a context-level timeout for upload operations. `http_request_timeout` sets a per-request HTTP client timeout that applies to all operations (upload, download, delete, list, etc.).
25+
2226
**Usage examples:**
2327
``` bash
2428
# Upload a blob
@@ -66,7 +70,7 @@ go test $(go list ./azurebs/... | grep -v integration)
6670
1. Export the following variables into your environment.
6771

6872
```bash
69-
export ACCOUNT_NAME=<your Azure accounnt name>
73+
export ACCOUNT_NAME=<your Azure account name>
7074
export ACCOUNT_KEY=<your Azure account key>
7175
export CONTAINER_NAME=<the target container name>
7276
```

azurebs/client/storage_client.go

Lines changed: 63 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77
"fmt"
88
"io"
99
"log/slog"
10+
"net/http"
1011
"os"
1112
"strconv"
1213
"strings"
@@ -104,9 +105,45 @@ func createContext(dsc DefaultStorageClient) (context.Context, context.CancelFun
104105
}
105106

106107
type DefaultStorageClient struct {
107-
credential *azblob.SharedKeyCredential
108-
serviceURL string
109-
storageConfig config.AZStorageConfig
108+
credential *azblob.SharedKeyCredential
109+
serviceURL string
110+
storageConfig config.AZStorageConfig
111+
httpRequestTimeout time.Duration
112+
}
113+
114+
// clientOptions returns azblob.ClientOptions with a timeout-configured http.Client,
115+
// or nil when no http_request_timeout is set (use SDK defaults).
116+
func (dsc DefaultStorageClient) blockblobClientOptions() *blockblob.ClientOptions {
117+
if dsc.httpRequestTimeout == 0 {
118+
return nil
119+
}
120+
return &blockblob.ClientOptions{
121+
ClientOptions: azcore.ClientOptions{
122+
Transport: &http.Client{Timeout: dsc.httpRequestTimeout},
123+
},
124+
}
125+
}
126+
127+
func (dsc DefaultStorageClient) blobClientOptions() *azBlob.ClientOptions {
128+
if dsc.httpRequestTimeout == 0 {
129+
return nil
130+
}
131+
return &azBlob.ClientOptions{
132+
ClientOptions: azcore.ClientOptions{
133+
Transport: &http.Client{Timeout: dsc.httpRequestTimeout},
134+
},
135+
}
136+
}
137+
138+
func (dsc DefaultStorageClient) containerClientOptions() *azContainer.ClientOptions {
139+
if dsc.httpRequestTimeout == 0 {
140+
return nil
141+
}
142+
return &azContainer.ClientOptions{
143+
ClientOptions: azcore.ClientOptions{
144+
Transport: &http.Client{Timeout: dsc.httpRequestTimeout},
145+
},
146+
}
110147
}
111148

112149
func NewStorageClient(storageConfig config.AZStorageConfig) (StorageClient, error) {
@@ -115,9 +152,19 @@ func NewStorageClient(storageConfig config.AZStorageConfig) (StorageClient, erro
115152
return nil, err
116153
}
117154

155+
httpRequestTimeout, err := storageConfig.HTTPRequestTimeoutValue()
156+
if err != nil {
157+
return nil, err
158+
}
159+
118160
serviceURL := fmt.Sprintf("https://%s.%s/%s", storageConfig.AccountName, storageConfig.StorageEndpoint(), storageConfig.ContainerName)
119161

120-
return DefaultStorageClient{credential: credential, serviceURL: serviceURL, storageConfig: storageConfig}, nil
162+
return DefaultStorageClient{
163+
credential: credential,
164+
serviceURL: serviceURL,
165+
storageConfig: storageConfig,
166+
httpRequestTimeout: httpRequestTimeout,
167+
}, nil
121168
}
122169

123170
func (dsc DefaultStorageClient) Upload(
@@ -138,7 +185,7 @@ func (dsc DefaultStorageClient) Upload(
138185
}
139186
defer cancel()
140187

141-
client, err := blockblob.NewClientWithSharedKeyCredential(blobURL, dsc.credential, nil)
188+
client, err := blockblob.NewClientWithSharedKeyCredential(blobURL, dsc.credential, dsc.blockblobClientOptions())
142189
if err != nil {
143190
return nil, err
144191
}
@@ -173,7 +220,7 @@ func (dsc DefaultStorageClient) UploadStream(
173220
}
174221
defer cancel()
175222

176-
client, err := blockblob.NewClientWithSharedKeyCredential(blobURL, dsc.credential, nil)
223+
client, err := blockblob.NewClientWithSharedKeyCredential(blobURL, dsc.credential, dsc.blockblobClientOptions())
177224
if err != nil {
178225
return err
179226
}
@@ -196,7 +243,7 @@ func (dsc DefaultStorageClient) Download(
196243
) error {
197244
blobURL := fmt.Sprintf("%s/%s", dsc.serviceURL, source)
198245
slog.Info("Downloading blob from container", "container", dsc.storageConfig.ContainerName, "blob", source, "local_file", dest.Name())
199-
client, err := blockblob.NewClientWithSharedKeyCredential(blobURL, dsc.credential, nil)
246+
client, err := blockblob.NewClientWithSharedKeyCredential(blobURL, dsc.credential, dsc.blockblobClientOptions())
200247
if err != nil {
201248
return err
202249
}
@@ -226,7 +273,7 @@ func (dsc DefaultStorageClient) Copy(
226273
srcURL := fmt.Sprintf("%s/%s", dsc.serviceURL, srcBlob)
227274
destURL := fmt.Sprintf("%s/%s", dsc.serviceURL, destBlob)
228275

229-
destClient, err := blockblob.NewClientWithSharedKeyCredential(destURL, dsc.credential, nil)
276+
destClient, err := blockblob.NewClientWithSharedKeyCredential(destURL, dsc.credential, dsc.blockblobClientOptions())
230277
if err != nil {
231278
return fmt.Errorf("failed to create destination client: %w", err)
232279
}
@@ -268,7 +315,7 @@ func (dsc DefaultStorageClient) Delete(
268315
blobURL := fmt.Sprintf("%s/%s", dsc.serviceURL, dest)
269316

270317
slog.Info("Deleting blob from container", "container", dsc.storageConfig.ContainerName, "blob", dest, "url", blobURL)
271-
client, err := blockblob.NewClientWithSharedKeyCredential(blobURL, dsc.credential, nil)
318+
client, err := blockblob.NewClientWithSharedKeyCredential(blobURL, dsc.credential, dsc.blockblobClientOptions())
272319
if err != nil {
273320
return err
274321
}
@@ -295,7 +342,7 @@ func (dsc DefaultStorageClient) DeleteRecursive(
295342
slog.Info("Deleting all blobs in container", "container", dsc.storageConfig.ContainerName)
296343
}
297344

298-
containerClient, err := azContainer.NewClientWithSharedKeyCredential(dsc.serviceURL, dsc.credential, nil)
345+
containerClient, err := azContainer.NewClientWithSharedKeyCredential(dsc.serviceURL, dsc.credential, dsc.containerClientOptions())
299346
if err != nil {
300347
return fmt.Errorf("failed to create container client: %w", err)
301348
}
@@ -315,7 +362,7 @@ func (dsc DefaultStorageClient) DeleteRecursive(
315362

316363
for _, blob := range resp.Segment.BlobItems {
317364
blobURL := fmt.Sprintf("%s/%s", dsc.serviceURL, *blob.Name)
318-
blobClient, err := blockblob.NewClientWithSharedKeyCredential(blobURL, dsc.credential, nil)
365+
blobClient, err := blockblob.NewClientWithSharedKeyCredential(blobURL, dsc.credential, dsc.blockblobClientOptions())
319366
if err != nil {
320367
slog.Error("Failed to create blob client", "blob", *blob.Name, "error", err)
321368
continue
@@ -338,7 +385,7 @@ func (dsc DefaultStorageClient) Exists(
338385
blobURL := fmt.Sprintf("%s/%s", dsc.serviceURL, dest)
339386

340387
slog.Info("Checking if blob exists", "container", dsc.storageConfig.ContainerName, "blob", dest, "url", blobURL)
341-
client, err := blockblob.NewClientWithSharedKeyCredential(blobURL, dsc.credential, nil)
388+
client, err := blockblob.NewClientWithSharedKeyCredential(blobURL, dsc.credential, dsc.blockblobClientOptions())
342389
if err != nil {
343390
return false, err
344391
}
@@ -365,7 +412,7 @@ func (dsc DefaultStorageClient) SignedUrl(
365412
blobURL := fmt.Sprintf("%s/%s", dsc.serviceURL, dest)
366413

367414
slog.Info("Generating SAS URL for blob", "container", dsc.storageConfig.ContainerName, "blob", dest, "request_type", requestType, "expiration", expiration)
368-
client, err := azBlob.NewClientWithSharedKeyCredential(blobURL, dsc.credential, nil)
415+
client, err := azBlob.NewClientWithSharedKeyCredential(blobURL, dsc.credential, dsc.blobClientOptions())
369416
if err != nil {
370417
return "", err
371418
}
@@ -398,7 +445,7 @@ func (dsc DefaultStorageClient) List(
398445
slog.Info("Listing blobs in container", "container", dsc.storageConfig.ContainerName)
399446
}
400447

401-
client, err := azContainer.NewClientWithSharedKeyCredential(dsc.serviceURL, dsc.credential, nil)
448+
client, err := azContainer.NewClientWithSharedKeyCredential(dsc.serviceURL, dsc.credential, dsc.containerClientOptions())
402449
if err != nil {
403450
return nil, fmt.Errorf("failed to create container client: %w", err)
404451
}
@@ -437,7 +484,7 @@ func (dsc DefaultStorageClient) Properties(
437484
blobURL := fmt.Sprintf("%s/%s", dsc.serviceURL, dest)
438485

439486
slog.Info("Getting properties for blob", "container", dsc.storageConfig.ContainerName, "blob", dest, "url", blobURL)
440-
client, err := blockblob.NewClientWithSharedKeyCredential(blobURL, dsc.credential, nil)
487+
client, err := blockblob.NewClientWithSharedKeyCredential(blobURL, dsc.credential, dsc.blockblobClientOptions())
441488
if err != nil {
442489
return err
443490
}
@@ -469,7 +516,7 @@ func (dsc DefaultStorageClient) Properties(
469516
func (dsc DefaultStorageClient) EnsureContainerExists() error {
470517
slog.Info("Ensuring container exists", "container", dsc.storageConfig.ContainerName)
471518

472-
containerClient, err := azContainer.NewClientWithSharedKeyCredential(dsc.serviceURL, dsc.credential, nil)
519+
containerClient, err := azContainer.NewClientWithSharedKeyCredential(dsc.serviceURL, dsc.credential, dsc.containerClientOptions())
473520
if err != nil {
474521
return fmt.Errorf("failed to create container client: %w", err)
475522
}

azurebs/config/config.go

Lines changed: 34 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,9 @@ package config
33
import (
44
"encoding/json"
55
"errors"
6+
"fmt"
67
"io"
8+
"time"
79

810
"github.com/Azure/azure-sdk-for-go/sdk/azcore/cloud"
911
)
@@ -27,11 +29,34 @@ func init() {
2729
}
2830

2931
type AZStorageConfig struct {
30-
AccountName string `json:"account_name"`
31-
AccountKey string `json:"account_key"`
32-
ContainerName string `json:"container_name"`
33-
Environment string `json:"environment"`
34-
Timeout string `json:"put_timeout_in_seconds"`
32+
AccountName string `json:"account_name"`
33+
AccountKey string `json:"account_key"`
34+
ContainerName string `json:"container_name"`
35+
Environment string `json:"environment"`
36+
Timeout string `json:"put_timeout_in_seconds"`
37+
HTTPRequestTimeout string `json:"http_request_timeout"`
38+
}
39+
40+
// ErrNonPositiveHTTPRequestTimeout is returned when http_request_timeout is <= 0.
41+
var ErrNonPositiveHTTPRequestTimeout = errors.New("http_request_timeout must be greater than 0")
42+
43+
// HTTPRequestTimeoutValue parses HTTPRequestTimeout as a Go duration string.
44+
// Returns 0 (no timeout) if the field is empty.
45+
func (c *AZStorageConfig) HTTPRequestTimeoutValue() (time.Duration, error) {
46+
if c.HTTPRequestTimeout == "" {
47+
return 0, nil
48+
}
49+
50+
d, err := time.ParseDuration(c.HTTPRequestTimeout)
51+
if err != nil {
52+
return 0, fmt.Errorf("invalid http_request_timeout: %w", err)
53+
}
54+
55+
if d <= 0 {
56+
return 0, ErrNonPositiveHTTPRequestTimeout
57+
}
58+
59+
return d, nil
3560
}
3661

3762
// NewFromReader returns a new azure-storage-cli configuration struct from the contents of reader.
@@ -53,6 +78,10 @@ func NewFromReader(reader io.Reader) (AZStorageConfig, error) {
5378
return AZStorageConfig{}, err
5479
}
5580

81+
if _, err = config.HTTPRequestTimeoutValue(); err != nil {
82+
return AZStorageConfig{}, err
83+
}
84+
5685
return config, nil
5786
}
5887

azurebs/config/config_test.go

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package config_test
33
import (
44
"bytes"
55
"errors"
6+
"time"
67

78
. "github.com/onsi/ginkgo/v2"
89
. "github.com/onsi/gomega"
@@ -87,6 +88,46 @@ var _ = Describe("Config", func() {
8788
})
8889
})
8990
})
91+
Context("http_request_timeout", func() {
92+
When("not set", func() {
93+
It("returns 0 duration", func() {
94+
configJson := []byte(`{"account_name": "a", "account_key": "b", "container_name": "c"}`)
95+
cfg, err := config.NewFromReader(bytes.NewReader(configJson))
96+
Expect(err).ToNot(HaveOccurred())
97+
d, err := cfg.HTTPRequestTimeoutValue()
98+
Expect(err).ToNot(HaveOccurred())
99+
Expect(d).To(Equal(time.Duration(0)))
100+
})
101+
})
102+
103+
When("set to a valid duration", func() {
104+
It("returns the parsed duration", func() {
105+
configJson := []byte(`{"account_name": "a", "account_key": "b", "container_name": "c", "http_request_timeout": "30s"}`)
106+
cfg, err := config.NewFromReader(bytes.NewReader(configJson))
107+
Expect(err).ToNot(HaveOccurred())
108+
d, err := cfg.HTTPRequestTimeoutValue()
109+
Expect(err).ToNot(HaveOccurred())
110+
Expect(d).To(Equal(30 * time.Second))
111+
})
112+
})
113+
114+
When("set to an invalid duration string", func() {
115+
It("returns an error", func() {
116+
configJson := []byte(`{"account_name": "a", "account_key": "b", "container_name": "c", "http_request_timeout": "not-a-duration"}`)
117+
_, err := config.NewFromReader(bytes.NewReader(configJson))
118+
Expect(err).To(HaveOccurred())
119+
Expect(err.Error()).To(ContainSubstring("invalid http_request_timeout"))
120+
})
121+
})
122+
123+
When("set to a non-positive duration", func() {
124+
It("returns an error", func() {
125+
configJson := []byte(`{"account_name": "a", "account_key": "b", "container_name": "c", "http_request_timeout": "-5s"}`)
126+
_, err := config.NewFromReader(bytes.NewReader(configJson))
127+
Expect(err).To(MatchError(config.ErrNonPositiveHTTPRequestTimeout))
128+
})
129+
})
130+
})
90131
})
91132

92133
type explodingReader struct{}

0 commit comments

Comments
 (0)