Skip to content

Commit 418e68a

Browse files
authored
Expose fetch observation metadata (#58)
1 parent 7e55ae2 commit 418e68a

6 files changed

Lines changed: 367 additions & 15 deletions

File tree

README.md

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -351,6 +351,32 @@ io.Copy(dst, artifact.Body)
351351

352352
The fetcher uses DNS caching (5-minute refresh), connection pooling, and a 5-minute timeout suited for large artifacts. It retries on rate limits and server errors with exponential backoff and jitter.
353353

354+
### Observing artifact responses
355+
356+
Use `FetchObserved` when the response metadata and downloaded content digests need to be retained:
357+
358+
```go
359+
artifact, err := f.FetchObserved(ctx, url)
360+
if err != nil {
361+
log.Fatal(err)
362+
}
363+
defer artifact.Body.Close()
364+
365+
if _, err := io.Copy(dst, artifact.Body); err != nil {
366+
log.Fatal(err)
367+
}
368+
if !artifact.Observation.Complete {
369+
log.Fatal("artifact body did not reach EOF")
370+
}
371+
372+
fmt.Println(artifact.Observation.RequestedURL)
373+
fmt.Println(artifact.Observation.FinalURL)
374+
fmt.Println(artifact.Observation.ByteCount)
375+
fmt.Println(artifact.Observation.Digests["sha256"])
376+
```
377+
378+
The observation includes the time to receive the final response headers, status, declared size, media type, and an allow-list of response headers: `Accept-Ranges`, `Cache-Control`, `Content-Disposition`, `Content-Encoding`, `Content-Length`, `Content-Range`, `Digest`, `ETag`, `Expires`, and `Last-Modified`. SHA-256 and SHA-512 digests use lowercase hexadecimal encoding. Byte counts and digests remain unset until the stream reaches EOF, so a partial download cannot appear complete. Request and authentication headers are not copied into the observation.
379+
354380
### Per-request headers
355381

356382
Use `FetchWithHeaders` to pass HTTP headers for a single request. This is useful when the auth token varies per request or is obtained dynamically (e.g. Docker Hub token exchange):

fetch/circuit_breaker.go

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,37 @@ func (cbf *CircuitBreakerFetcher) FetchWithHeaders(ctx context.Context, fetchURL
105105
return artifact, fetchErr
106106
}
107107

108+
// FetchObserved wraps the underlying fetcher's FetchObserved with circuit breaker logic.
109+
func (cbf *CircuitBreakerFetcher) FetchObserved(ctx context.Context, fetchURL string) (*ObservedArtifact, error) {
110+
return cbf.FetchObservedWithHeaders(ctx, fetchURL, nil)
111+
}
112+
113+
// FetchObservedWithHeaders wraps the underlying fetcher's FetchObservedWithHeaders with circuit breaker logic.
114+
func (cbf *CircuitBreakerFetcher) FetchObservedWithHeaders(ctx context.Context, fetchURL string, headers http.Header) (*ObservedArtifact, error) {
115+
registry := extractRegistry(fetchURL)
116+
breaker := cbf.getBreaker(registry)
117+
118+
if !breaker.Ready() {
119+
return nil, fmt.Errorf("circuit breaker open for registry %s: %w", registry, ErrUpstreamDown)
120+
}
121+
122+
var artifact *ObservedArtifact
123+
var fetchErr error
124+
err := breaker.Call(func() error {
125+
artifact, fetchErr = cbf.fetcher.FetchObservedWithHeaders(ctx, fetchURL, headers)
126+
if errors.Is(fetchErr, ErrNotFound) {
127+
return nil
128+
}
129+
return fetchErr
130+
}, 0)
131+
132+
if err != nil {
133+
return nil, err
134+
}
135+
136+
return artifact, fetchErr
137+
}
138+
108139
// Head wraps the underlying fetcher's Head with circuit breaker logic.
109140
func (cbf *CircuitBreakerFetcher) Head(ctx context.Context, headURL string) (size int64, contentType string, err error) {
110141
registry := extractRegistry(headURL)

fetch/circuit_breaker_test.go

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,27 @@ func TestCircuitBreakerFetch_Success(t *testing.T) {
3636
}
3737
}
3838

39+
func TestCircuitBreakerFetchObserved_Success(t *testing.T) {
40+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
41+
_, _ = w.Write([]byte("test content"))
42+
}))
43+
defer server.Close()
44+
45+
cbFetcher := NewCircuitBreakerFetcher(NewFetcher())
46+
artifact, err := cbFetcher.FetchObserved(context.Background(), server.URL+"/test.tar.gz")
47+
if err != nil {
48+
t.Fatalf("FetchObserved failed: %v", err)
49+
}
50+
defer func() { _ = artifact.Body.Close() }()
51+
52+
if _, err := io.ReadAll(artifact.Body); err != nil {
53+
t.Fatalf("ReadAll failed: %v", err)
54+
}
55+
if !artifact.Observation.Complete {
56+
t.Error("observation is incomplete after reading the response body")
57+
}
58+
}
59+
3960
func TestCircuitBreakerHead_Success(t *testing.T) {
4061
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
4162
if r.Method != http.MethodHead {

fetch/fetcher.go

Lines changed: 53 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -248,6 +248,27 @@ func (f *Fetcher) Fetch(ctx context.Context, url string) (*Artifact, error) {
248248
// FetchWithHeaders downloads an artifact from the given URL with additional HTTP headers.
249249
// The caller must close the returned Artifact.Body when done.
250250
func (f *Fetcher) FetchWithHeaders(ctx context.Context, url string, headers http.Header) (*Artifact, error) {
251+
artifact, _, err := f.fetch(ctx, url, headers, false)
252+
return artifact, err
253+
}
254+
255+
// FetchObserved downloads an artifact and records metadata about the response.
256+
// The caller must read the body to EOF before treating the observation as complete.
257+
func (f *Fetcher) FetchObserved(ctx context.Context, url string) (*ObservedArtifact, error) {
258+
return f.FetchObservedWithHeaders(ctx, url, nil)
259+
}
260+
261+
// FetchObservedWithHeaders downloads an artifact with additional HTTP headers and
262+
// records metadata about the response. Request headers are not copied into the observation.
263+
func (f *Fetcher) FetchObservedWithHeaders(ctx context.Context, url string, headers http.Header) (*ObservedArtifact, error) {
264+
artifact, observation, err := f.fetch(ctx, url, headers, true)
265+
if err != nil {
266+
return nil, err
267+
}
268+
return &ObservedArtifact{Artifact: artifact, Observation: observation}, nil
269+
}
270+
271+
func (f *Fetcher) fetch(ctx context.Context, url string, headers http.Header, observe bool) (*Artifact, *FetchObservation, error) {
251272
var lastErr error
252273

253274
for attempt := 0; attempt <= f.maxRetries; attempt++ {
@@ -259,21 +280,21 @@ func (f *Fetcher) FetchWithHeaders(ctx context.Context, url string, headers http
259280

260281
select {
261282
case <-ctx.Done():
262-
return nil, ctx.Err()
283+
return nil, nil, ctx.Err()
263284
case <-time.After(delay):
264285
}
265286
}
266287

267-
artifact, err := f.doFetch(ctx, url, headers)
288+
artifact, observation, err := f.doFetch(ctx, url, headers, observe)
268289
if err == nil {
269-
return artifact, nil
290+
return artifact, observation, nil
270291
}
271292

272293
lastErr = err
273294

274295
// Don't retry on not found or client errors
275296
if errors.Is(err, ErrNotFound) {
276-
return nil, err
297+
return nil, nil, err
277298
}
278299

279300
// Retry on rate limit and server errors
@@ -282,16 +303,16 @@ func (f *Fetcher) FetchWithHeaders(ctx context.Context, url string, headers http
282303
}
283304

284305
// Don't retry on other errors (network issues will be wrapped)
285-
return nil, err
306+
return nil, nil, err
286307
}
287308

288-
return nil, lastErr
309+
return nil, nil, lastErr
289310
}
290311

291-
func (f *Fetcher) doFetch(ctx context.Context, url string, headers http.Header) (*Artifact, error) {
312+
func (f *Fetcher) doFetch(ctx context.Context, url string, headers http.Header, observe bool) (*Artifact, *FetchObservation, error) {
292313
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
293314
if err != nil {
294-
return nil, fmt.Errorf("creating request: %w", err)
315+
return nil, nil, fmt.Errorf("creating request: %w", err)
295316
}
296317

297318
req.Header.Set("User-Agent", f.userAgent)
@@ -311,9 +332,11 @@ func (f *Fetcher) doFetch(ctx context.Context, url string, headers http.Header)
311332
}
312333
}
313334

335+
startedAt := time.Now()
314336
resp, err := f.client.Do(req)
337+
responseTime := time.Since(startedAt)
315338
if err != nil {
316-
return nil, fmt.Errorf("fetching artifact: %w", err)
339+
return nil, nil, fmt.Errorf("fetching artifact: %w", err)
317340
}
318341

319342
switch {
@@ -325,29 +348,44 @@ func (f *Fetcher) doFetch(ctx context.Context, url string, headers http.Header)
325348
}
326349
}
327350

328-
return &Artifact{
351+
artifact := &Artifact{
329352
Body: resp.Body,
330353
Size: size,
331354
ContentType: resp.Header.Get("Content-Type"),
332355
ETag: resp.Header.Get("ETag"),
333-
}, nil
356+
}
357+
if !observe {
358+
return artifact, nil, nil
359+
}
360+
361+
observation := &FetchObservation{
362+
RequestedURL: url,
363+
FinalURL: resp.Request.URL.String(),
364+
ResponseTime: responseTime,
365+
StatusCode: resp.StatusCode,
366+
Headers: copyObservedHeaders(resp.Header),
367+
DeclaredSize: size,
368+
MediaType: resp.Header.Get("Content-Type"),
369+
}
370+
artifact.Body = newObservedBody(resp.Body, observation)
371+
return artifact, observation, nil
334372

335373
case resp.StatusCode == http.StatusNotFound:
336374
_ = resp.Body.Close()
337-
return nil, ErrNotFound
375+
return nil, nil, ErrNotFound
338376

339377
case resp.StatusCode == http.StatusTooManyRequests:
340378
_ = resp.Body.Close()
341-
return nil, ErrRateLimited
379+
return nil, nil, ErrRateLimited
342380

343381
case resp.StatusCode >= serverErrThreshold:
344382
_ = resp.Body.Close()
345-
return nil, ErrUpstreamDown
383+
return nil, nil, ErrUpstreamDown
346384

347385
default:
348386
body, _ := io.ReadAll(io.LimitReader(resp.Body, maxErrBodySize))
349387
_ = resp.Body.Close()
350-
return nil, fmt.Errorf("unexpected status %d: %s", resp.StatusCode, string(body))
388+
return nil, nil, fmt.Errorf("unexpected status %d: %s", resp.StatusCode, string(body))
351389
}
352390
}
353391

fetch/observation.go

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
package fetch
2+
3+
import (
4+
"crypto/sha256"
5+
"crypto/sha512"
6+
"encoding/hex"
7+
"hash"
8+
"io"
9+
"net/http"
10+
"time"
11+
)
12+
13+
var observedResponseHeaders = []string{
14+
"Accept-Ranges",
15+
"Cache-Control",
16+
"Content-Disposition",
17+
"Content-Encoding",
18+
"Content-Length",
19+
"Content-Range",
20+
"Digest",
21+
"ETag",
22+
"Expires",
23+
"Last-Modified",
24+
}
25+
26+
// FetchObservation contains metadata about a successful artifact response.
27+
// ByteCount and Digests are populated, and Complete is set to true, only after
28+
// the response body reaches EOF.
29+
type FetchObservation struct {
30+
RequestedURL string
31+
FinalURL string
32+
ResponseTime time.Duration // Time until the final response headers arrived.
33+
StatusCode int
34+
Headers http.Header // Allow-listed response headers only.
35+
DeclaredSize int64
36+
MediaType string
37+
ByteCount int64
38+
Digests map[string]string
39+
Complete bool
40+
}
41+
42+
// ObservedArtifact contains an artifact and its fetch observation.
43+
type ObservedArtifact struct {
44+
*Artifact
45+
Observation *FetchObservation
46+
}
47+
48+
type observedBody struct {
49+
body io.ReadCloser
50+
observation *FetchObservation
51+
sha256 hash.Hash
52+
sha512 hash.Hash
53+
byteCount int64
54+
}
55+
56+
func newObservedBody(body io.ReadCloser, observation *FetchObservation) io.ReadCloser {
57+
return &observedBody{
58+
body: body,
59+
observation: observation,
60+
sha256: sha256.New(),
61+
sha512: sha512.New(),
62+
}
63+
}
64+
65+
func (b *observedBody) Read(p []byte) (int, error) {
66+
n, err := b.body.Read(p)
67+
if n > 0 {
68+
b.byteCount += int64(n)
69+
_, _ = b.sha256.Write(p[:n])
70+
_, _ = b.sha512.Write(p[:n])
71+
}
72+
if err == io.EOF && !b.observation.Complete {
73+
b.observation.ByteCount = b.byteCount
74+
b.observation.Digests = map[string]string{
75+
"sha256": hex.EncodeToString(b.sha256.Sum(nil)),
76+
"sha512": hex.EncodeToString(b.sha512.Sum(nil)),
77+
}
78+
b.observation.Complete = true
79+
}
80+
return n, err
81+
}
82+
83+
func (b *observedBody) Close() error {
84+
return b.body.Close()
85+
}
86+
87+
func copyObservedHeaders(headers http.Header) http.Header {
88+
observed := make(http.Header)
89+
for _, name := range observedResponseHeaders {
90+
if values := headers.Values(name); len(values) > 0 {
91+
observed[http.CanonicalHeaderKey(name)] = append([]string(nil), values...)
92+
}
93+
}
94+
return observed
95+
}

0 commit comments

Comments
 (0)