go / s3
The AWS SDK for Go is a large dependency tree. I call three S3 operations: list objects, get an object, and presign a PUT. The SDK's main work is request signing, and AWS Signature Version 4 is a few hundred lines, so I wrote a client.
Client
s3.Client holds two HTTP clients: one with a 10-second timeout for
small calls, and one without a timeout for streaming downloads:
type Client struct {
region string
signer *SigV4
httpClient *http.Client
// streamClient is separate from httpClient because GetObject returns
// a streaming body that callers may read for much longer than the
// 10s timeout we use for small request/response calls.
streamClient *http.Client
// endpointFn is a hook so tests can route requests to httptest.
endpointFn func(bucket string) string
}
func NewClient(region, accessKeyID, secretAccessKey string) (*Client, error) {
signer, err := NewSigV4("s3", region, accessKeyID, secretAccessKey)
if err != nil {
return nil, err
}
return &Client{
region: region,
signer: signer,
httpClient: &http.Client{Timeout: 10 * time.Second},
streamClient: &http.Client{},
endpointFn: func(bucket string) string {
return fmt.Sprintf("https://%s.s3.%s.amazonaws.com", bucket, region)
},
}, nil
}
SigV4
The signer supports header signing for GET requests and query-string presigning for PUT URLs. It skips session tokens, asymmetric signatures, event streams, and path normalization.
NewSigV4 validates every field, so a misconfigured worker fails at
startup:
func NewSigV4(service, region, accessKeyID, secretAccessKey string) (*SigV4, error) {
// ... trim each field
if service == "" || region == "" || accessKeyID == "" || secretAccessKey == "" {
return nil, errors.New("s3: missing service, region, access_key_id, or secret_access_key")
}
// ...
}
Signing builds the canonical request, derives the string to sign, and computes the signature. The canonical request joins six elements with newlines:
func (s *SigV4) canonicalRequest(method string, u *url.URL, headers map[string]string, contentSHA string) string {
return strings.Join([]string{
strings.ToUpper(method),
canonicalPath(u),
canonicalQuery(u.RawQuery),
canonicalHeaders(headers) + "\n",
signedHeaders(headers),
contentSHA,
}, "\n")
}
The signing key derives from a chain of HMAC calculations over the date, region, service, and a fixed string:
func (s *SigV4) signature(date, stringToSign string) string {
kDate := hmacSHA256([]byte("AWS4"+s.secretAccessKey), date)
kRegion := hmacSHA256(kDate, s.region)
kService := hmacSHA256(kRegion, s.service)
kSigning := hmacSHA256(kService, "aws4_request")
return hex.EncodeToString(hmacSHA256(kSigning, stringToSign))
}
Escaping
SigV4 requires RFC 3986 URL escaping. url.QueryEscape differs in two
ways: a space encodes as +, and a tilde encodes as %7E. A wrong
escape produces a signature mismatch, which S3 reports as a 403:
func escape(s string) string {
escaped := url.QueryEscape(s)
escaped = strings.ReplaceAll(escaped, "+", "%20")
escaped = strings.ReplaceAll(escaped, "%7E", "~")
return escaped
}
Presigned PUT
PresignedPutURL makes no HTTP call. It signs a URL the uploader
PUTs to. The uploader must send each signed header, such as
Content-Type, with the same value:
func (c *Client) PresignedPutURL(bucket, key, contentType string, expiresIn time.Duration) (string, error) {
return c.signer.PresignURL(http.MethodPut, c.objectURL(bucket, key), expiresIn, map[string]string{
"Content-Type": contentType,
}, time.Now())
}
Presigning puts the auth in query params such as X-Amz-Signature.
It sets the payload hash to UNSIGNED-PAYLOAD because the signer
never sees the bytes.
Two retry paths
ListObjectsV2 buffers a small XML body, so it uses the
backoff helper. The build closure re-signs each
attempt because the signature binds to x-amz-date:
func (c *Client) getBytes(ctx context.Context, url string) ([]byte, error) {
build := func() (*http.Request, error) {
signed, err := c.signer.SignRequest(http.MethodGet, url, nil, nil, time.Now())
if err != nil {
return nil, fmt.Errorf("sign: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, err
}
for k, v := range signed {
req.Header.Set(k, v)
}
return req, nil
}
res, err := httputil.Do(ctx, build, httputil.Config{
Client: c.httpClient,
TransientCodes: transientCodes,
})
// ... check status, return res.Body
}
httputil.Do consumes the response body, and GetObject must return
the open body to the caller. So GetObject has its own retry loop. It
reuses httputil.DefaultRetryDelays and httputil.Sleep, so tests
set one zero delay:
func (c *Client) GetObject(ctx context.Context, bucket, key string) (io.ReadCloser, error) {
u := c.objectURL(bucket, key)
var lastErr error
for i, delay := range httputil.DefaultRetryDelays {
// ... re-sign, build request
resp, err := c.streamClient.Do(req)
// ... on transient error or transient code, sleep and continue
if resp.StatusCode/100 != 2 {
resp.Body.Close()
return nil, fmt.Errorf("HTTP %d", resp.StatusCode)
}
return resp.Body, nil // caller closes
}
// ... return lastErr
}
Tests
endpointFn routes requests to an httptest.NewServer, so the client
runs without AWS. The signer has its own tests that pin the signature
against known inputs.