go / s3
The AWS SDK for Go is the strongest case for a hand-rolled client. It's a large dependency tree to call three S3 operations: list objects, get an object, and presign a PUT. The only real work the SDK saves is request signing, and AWS Signature Version 4 is a few hundred lines.
The client
s3.Client implements only the three operations I use. Two HTTP
clients: a 10s-timeout one for small request/response calls, and
a no-timeout one for streaming downloads that callers may read
for much longer:
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 two modes: header-signing for GET requests and query-string presigning for PUT URLs. Session tokens, asymmetric signatures, event streams, and path normalization are all in the spec and none are implemented, because I don't use them.
Construction validates every field, so a misconfigured worker fails at startup instead of producing signatures S3 silently rejects:
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 is where SigV4 is exacting: method, path, sorted query, sorted headers, signed header list, and payload hash, each newline-joined:
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 is a chain of HMACs over date, region, service, and a fixed terminator:
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 is the trap
SigV4 wants RFC 3986 escaping that differs from Go's stdlib in
two spots: space is %20, not +, and ~ stays unescaped. Get
this wrong and the signature mismatches with an opaque 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
can PUT to directly. Any headers signed here (like Content-Type)
must be sent verbatim by the uploader or the signature won't
validate:
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 (X-Amz-Signature and
friends) with UNSIGNED-PAYLOAD as the content hash, since the
signer never sees the bytes the client will upload.
Two retry paths
ListObjectsV2 buffers a small XML body, so it goes through the
shared backoff helper. The build closure re-signs
per attempt, because the signature binds to x-amz-date and a
retry crosses a second boundary:
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
}
GetObject can't use the helper: httputil.Do consumes the
response body, but a streaming download must hand the open body
back to the caller. So it keeps its own retry loop, still reusing
httputil.DefaultRetryDelays and httputil.Sleep so tests share
the same zero-delay knob:
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
}
The caller closes the returned ReadCloser. That single
ownership difference is why streaming needs its own loop.
Tests
endpointFn routes requests to an httptest.NewServer, so the
whole client (signing included) runs without AWS. The signer has
its own tests that pin the signature against known inputs, so a
stray escaping change fails loudly instead of in production with
a 403.