Serve both image routes with one function and one error mapping (closes #316)
check / check (push) Canceled after 0s

HandleImage parses the path and verifies the signature, and HandleImageEnc
decrypts the token. Each then calls serveImage, which chooses the format for
auto, gets the image and serves it: the headers, ETag and 304, HEAD, the copy
and the log line. respondImageError answers the image service's errors on both
routes, and handleImageError is gone. /v1/image/ now answers 504 when the
upstream sends no response headers within upstream_fetch_timeout, as README.md
documented for /v1/e/. /v1/e/ now logs these errors at error level, as
/v1/image/ does. README.md's route tables list 504 for /v1/image/. A new test
sends the same upstream fetch errors through both routes.

Model: opus-5-5
This commit is contained in:
2026-10-26 13:23:57 +00:00
parent 77d55abb7e
commit 76d2304a3c
8 changed files with 177 additions and 241 deletions
@@ -1,9 +1,11 @@
package handlers
import (
"context"
"encoding/json"
"fmt"
"image/color"
"io"
"log/slog"
"net/http"
"net/http/httptest"
@@ -414,3 +416,66 @@ func TestHandleImage_HashInSourcePathOrQuery_Returns400(t *testing.T) {
})
}
}
// failingFetcher answers every fetch with its error.
type failingFetcher struct {
err error
}
// Fetch returns f's error.
func (f failingFetcher) Fetch(
context.Context, imagerequest.Source,
) (*httpfetcher.FetchResult, error) {
return nil, f.err
}
// TestImageRoutes_SameErrorAnswers checks that both image routes answer each
// upstream fetch error below with the same status. Each request gets routes of
// its own, so no request finds another's failed fetch in the negative cache.
func TestImageRoutes_SameErrorAnswers(t *testing.T) {
t.Parallel()
tests := []struct {
name string
err error
wantStatus int
}{
{"blocked upstream address", httpfetcher.ErrSSRFBlocked,
http.StatusForbidden},
{"upstream error status",
fmt.Errorf("%w: status 404", httpfetcher.ErrUpstreamError),
http.StatusBadGateway},
{"no response headers within the fetch timeout",
httpfetcher.ErrUpstreamTimeout, http.StatusGatewayTimeout},
{"no free upstream connection", httpfetcher.ErrTooManyConnections,
http.StatusServiceUnavailable},
{"Content-Type not a source format",
fmt.Errorf("%w: image/svg+xml", httpfetcher.ErrInvalidContentType),
http.StatusUnsupportedMediaType},
{"any other failure", io.ErrUnexpectedEOF,
http.StatusInternalServerError},
}
// The URL of the JPEG at photoPath on signedHost on each image route.
imageURLs := map[string]func(*testing.T, *Handlers) string{
"/v1/image/": signedPhotoURL,
"/v1/e/": encPhotoURL,
}
for _, tt := range tests {
for route, imageURL := range imageURLs {
t.Run(route+" "+tt.name, func(t *testing.T) {
t.Parallel()
h, routes := newRoutesFetchingThrough(t,
failingFetcher{err: tt.err}, slog.New(slog.DiscardHandler))
rec := sendGet(t, routes, imageURL(t, h))
if rec.Code != tt.wantStatus {
t.Errorf("status = %d, want %d: %s",
rec.Code, tt.wantStatus, rec.Body)
}
})
}
}
}