Check cached sources with upstream after the cache TTL (closes #69)
check / check (push) Canceled after 0s

Cached images were served until eviction, so upstream changes were
missed.

After 24 hours a request checks the source with upstream, sending its
stored ETag and Last-Modified. A 304 renews it with no download or
conversion. New bytes replace it; older variants are converted again.
A failed check serves the cached image and puts the URL in the
negative cache. Images not checked for 30 days are evicted.

Variant rows keep the source's ETag and Last-Modified for checking a
variant of an evicted source.

A variant file's modification time is its source's check time when it
was made or renewed. Hits read it without a database call. The ETag
includes it, so new bytes get a new ETag.

Model: opus-5-5
This commit is contained in:
clawbot committed 2026-11-11 11:40:36 +00:00
1 parent b3b8408d18
commit 445ff628fd
29 files changed
+1709 -269

No files matched your search

+54 -14
View File
@@ -80,8 +80,13 @@ var (
// Fetcher retrieves content from upstream origins.
type Fetcher interface {
// Fetch retrieves the content of the source image.
Fetch(ctx context.Context, source imagerequest.Source) (*FetchResult, error)
// Fetch retrieves the content of the source image. A non-empty
// ifNoneMatch or ifModifiedSince is sent as the request header of that
// name, and upstream may then answer 304 Not Modified with no content.
Fetch(
ctx context.Context, source imagerequest.Source,
ifNoneMatch, ifModifiedSince string,
) (*FetchResult, error)
}
// FetchResult contains the result of fetching from upstream.
@@ -223,9 +228,13 @@ func New(config *Config) *HTTPFetcher {
// Fetch retrieves the source image with SSRF protection, over https, or over
// http when AllowHTTP is set. When MaxConnections fetches are already in
// progress, it waits up to ConnectionWaitTimeout for one to finish, then
// fails with ErrTooManyConnections.
// fails with ErrTooManyConnections. A non-empty ifNoneMatch or
// ifModifiedSince is sent as the If-None-Match or If-Modified-Since header.
// Upstream's 304 Not Modified to such a request is a result with StatusCode
// 304 and empty Content; to any other request it is ErrUpstreamError.
func (f *HTTPFetcher) Fetch(
ctx context.Context, source imagerequest.Source,
ifNoneMatch, ifModifiedSince string,
) (*FetchResult, error) {
sourceURL := source.URL(f.config.AllowHTTP)
@@ -252,17 +261,7 @@ func (f *HTTPFetcher) Fetch(
req := &http.Request{
Method: http.MethodGet,
URL: sourceURL,
Header: make(http.Header),
}
req.Header.Set("User-Agent", userAgent)
req.Header.Set("Accept", strings.Join(allowedContentTypes(), ", "))
// The ID of the request this fetch serves, so the fetch can be found in
// the upstream host's logs
requestID := middleware.GetReqID(ctx)
if requestID != "" {
req.Header.Set(middleware.RequestIDHeader, requestID)
Header: requestHeaders(ctx, ifNoneMatch, ifModifiedSince),
}
// Use httptrace to capture connection details
@@ -291,6 +290,20 @@ func (f *HTTPFetcher) Fetch(
return nil, fmt.Errorf("upstream request failed: %w", err)
}
// The deferred release gives the connection back, as no body follows
conditional := ifNoneMatch != "" || ifModifiedSince != ""
if conditional && resp.StatusCode == http.StatusNotModified {
_ = resp.Body.Close()
return &FetchResult{
Content: http.NoBody,
Headers: resp.Header,
StatusCode: resp.StatusCode,
FetchDurationMs: fetchDuration.Milliseconds(),
RemoteAddr: remoteAddr,
}, nil
}
result, err := f.buildResult(resp, remoteAddr, fetchDuration, release)
if err != nil {
return nil, err
@@ -303,6 +316,33 @@ func (f *HTTPFetcher) Fetch(
return result, nil
}
// requestHeaders returns the headers of a fetch: User-Agent, Accept, the
// ID of the request the fetch serves, so the fetch can be found in the
// upstream host's logs, and a non-empty ifNoneMatch or ifModifiedSince.
func requestHeaders(
ctx context.Context, ifNoneMatch, ifModifiedSince string,
) http.Header {
header := make(http.Header)
header.Set("User-Agent", userAgent)
header.Set("Accept", strings.Join(allowedContentTypes(), ", "))
requestID := middleware.GetReqID(ctx)
if requestID != "" {
header.Set(middleware.RequestIDHeader, requestID)
}
if ifNoneMatch != "" {
header.Set("If-None-Match", ifNoneMatch)
}
if ifModifiedSince != "" {
header.Set("If-Modified-Since", ifModifiedSince)
}
return header
}
// acquireConnection takes a slot for host, then one of the slots shared by
// all hosts, and returns the func that gives both back. The host's slot
// comes first, so fetches queued for one busy host hold no shared slot.