Serve both image routes with one function and one error mapping (closes #316)
check / check (push) Canceled after 0s
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. 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/. respondImageError logs a 500 at error level and the other answers at warn. The image service alone logs a source refused for its first bytes. A failed write is logged once, at warn, with the request and the bytes served. README.md lists each image route's answers as bullets. Tests send the same errors through both routes. Model: opus-5-5
This commit is contained in:
+89
-84
@@ -46,30 +46,7 @@ func (s *Handlers) HandleImage() http.HandlerFunc {
|
||||
return
|
||||
}
|
||||
|
||||
// The signature covers the format auto, not the format chosen
|
||||
format, ok := s.chooseAutoFormat(w, r, req.Transform.Format)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
req.Transform.Format = format
|
||||
|
||||
// Get cache key for logging
|
||||
cacheKey := imagerequest.CacheKey(req)
|
||||
|
||||
// Get the image (from cache or fetch/process)
|
||||
startTime := time.Now()
|
||||
|
||||
resp, err := s.imgSvc.Get(r.Context(), req)
|
||||
if err != nil {
|
||||
s.respondImageError(w, req, err)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
defer func() { _ = resp.Content.Close() }()
|
||||
|
||||
s.writeImageResponse(w, r, req, expires, resp, cacheKey, startTime)
|
||||
s.serveImage(w, r, req, expires)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -213,8 +190,8 @@ func (s *Handlers) validateSignature(
|
||||
}
|
||||
|
||||
// parseExpires reads the exp query parameter, a Unix time in seconds. An exp
|
||||
// missing from the URL gives the zero time, which the signature check takes
|
||||
// as no expiration. An exp in the URL that is not a whole number, an empty
|
||||
// missing from the URL gives the zero time, which the signature check refuses
|
||||
// as a missing exp. An exp in the URL that is not a whole number, an empty
|
||||
// one included, is an error naming exp and the value.
|
||||
func parseExpires(query url.Values) (time.Time, error) {
|
||||
if !query.Has("exp") {
|
||||
@@ -242,7 +219,11 @@ func (s *Handlers) HandleTooManyRequests() http.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
// respondImageError maps image retrieval errors to HTTP responses.
|
||||
// respondImageError answers err, the image service's error for req, on both
|
||||
// image routes. It logs a 500 at error level, as pixa's own failure. It logs
|
||||
// the other answers at warn level, since the client, the upstream or a full
|
||||
// limit caused them. The 415 for a source refused for its first bytes is not
|
||||
// logged here, because the image service logs it.
|
||||
func (s *Handlers) respondImageError(
|
||||
w http.ResponseWriter, req *imagerequest.Request, err error,
|
||||
) {
|
||||
@@ -258,59 +239,53 @@ func (s *Handlers) respondImageError(
|
||||
return
|
||||
}
|
||||
|
||||
s.log.Error("failed to get image",
|
||||
var (
|
||||
status int
|
||||
message string
|
||||
)
|
||||
|
||||
switch {
|
||||
case errors.Is(err, httpfetcher.ErrSSRFBlocked):
|
||||
status, message = http.StatusForbidden, "forbidden"
|
||||
case errors.Is(err, httpfetcher.ErrUpstreamError):
|
||||
status, message = http.StatusBadGateway, "upstream error"
|
||||
case errors.Is(err, httpfetcher.ErrUpstreamTimeout):
|
||||
status, message = http.StatusGatewayTimeout, "upstream timeout"
|
||||
case errors.Is(err, httpfetcher.ErrTooManyConnections),
|
||||
errors.Is(err, imageprocessor.ErrTooManyImages),
|
||||
errors.Is(err, imgcache.ErrProcessingTimedOut):
|
||||
status = http.StatusServiceUnavailable
|
||||
message = "server busy, try again later"
|
||||
case errors.Is(err, httpfetcher.ErrInvalidContentType),
|
||||
errors.Is(err, imageprocessor.ErrUnsupportedInputFormat):
|
||||
status = http.StatusUnsupportedMediaType
|
||||
message = "unsupported source format"
|
||||
case errors.Is(err, imageprocessor.ErrOutputTooLarge):
|
||||
status = http.StatusBadRequest
|
||||
message = fmt.Sprintf("size too large for the source: the side "+
|
||||
"computed from its proportions is above %d",
|
||||
imageprocessor.MaxOutputDimension)
|
||||
case errors.Is(err, imageprocessor.ErrAVIFOutputTooLarge):
|
||||
status = http.StatusBadRequest
|
||||
message = fmt.Sprintf("size too large for AVIF: the output "+
|
||||
"would be above %d pixels", imageprocessor.MaxAVIFOutputPixels)
|
||||
default:
|
||||
s.log.Error("failed to get image",
|
||||
"host", req.Source.Host,
|
||||
"path", req.Source.Path,
|
||||
"error", err,
|
||||
)
|
||||
s.respondError(w, "internal error", http.StatusInternalServerError)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
s.log.Warn("failed to get image",
|
||||
"host", req.Source.Host,
|
||||
"path", req.Source.Path,
|
||||
"error", err,
|
||||
)
|
||||
|
||||
// Check for specific error types
|
||||
if errors.Is(err, httpfetcher.ErrSSRFBlocked) {
|
||||
s.respondError(w, "forbidden", http.StatusForbidden)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if errors.Is(err, httpfetcher.ErrUpstreamError) {
|
||||
s.respondError(w, "upstream error", http.StatusBadGateway)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if errors.Is(err, httpfetcher.ErrTooManyConnections) ||
|
||||
errors.Is(err, imageprocessor.ErrTooManyImages) ||
|
||||
errors.Is(err, imgcache.ErrProcessingTimedOut) {
|
||||
s.respondError(w, "server busy, try again later",
|
||||
http.StatusServiceUnavailable)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if errors.Is(err, httpfetcher.ErrInvalidContentType) ||
|
||||
errors.Is(err, imageprocessor.ErrUnsupportedInputFormat) {
|
||||
s.respondError(w, "unsupported source format",
|
||||
http.StatusUnsupportedMediaType)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if errors.Is(err, imageprocessor.ErrOutputTooLarge) {
|
||||
s.respondError(w, fmt.Sprintf("size too large for the source: the side "+
|
||||
"computed from its proportions is above %d",
|
||||
imageprocessor.MaxOutputDimension), http.StatusBadRequest)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if errors.Is(err, imageprocessor.ErrAVIFOutputTooLarge) {
|
||||
s.respondError(w, fmt.Sprintf("size too large for AVIF: the output "+
|
||||
"would be above %d pixels", imageprocessor.MaxAVIFOutputPixels),
|
||||
http.StatusBadRequest)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
s.respondError(w, "internal error", http.StatusInternalServerError)
|
||||
s.respondError(w, message, status)
|
||||
}
|
||||
|
||||
// cacheControl returns the Cache-Control header for an image served through a
|
||||
@@ -367,14 +342,34 @@ func notModified(w http.ResponseWriter, r *http.Request, etag string) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// writeImageResponse writes headers and streams the image content,
|
||||
// handling conditional and HEAD requests. expires is the URL's expiry, which
|
||||
// ends the Cache-Control max-age.
|
||||
func (s *Handlers) writeImageResponse(
|
||||
// serveImage serves the image that req asks for. Both image routes call it
|
||||
// once they have the request and its URL's expiry, which ends the
|
||||
// Cache-Control max-age. It chooses the format for the format auto, answers
|
||||
// If-None-Match with 304 and HEAD with the headers only, and answers an error
|
||||
// of the image service with respondImageError.
|
||||
func (s *Handlers) serveImage(
|
||||
w http.ResponseWriter, r *http.Request,
|
||||
req *imagerequest.Request, expires time.Time, resp *imgcache.ImageResponse,
|
||||
cacheKey imagerequest.VariantKey, startTime time.Time,
|
||||
req *imagerequest.Request, expires time.Time,
|
||||
) {
|
||||
// The signature or the token covers the format auto, not the format chosen
|
||||
format, ok := s.chooseAutoFormat(w, r, req.Transform.Format)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
req.Transform.Format = format
|
||||
|
||||
startTime := time.Now()
|
||||
|
||||
resp, err := s.imgSvc.Get(r.Context(), req)
|
||||
if err != nil {
|
||||
s.respondImageError(w, req, err)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
defer func() { _ = resp.Content.Close() }()
|
||||
|
||||
// Set response headers
|
||||
w.Header().Set("Content-Type", resp.ContentType)
|
||||
|
||||
@@ -400,18 +395,28 @@ func (s *Handlers) writeImageResponse(
|
||||
// Stream the response
|
||||
w.WriteHeader(http.StatusOK)
|
||||
|
||||
// A failed write is most often a client that has gone away, not pixa's
|
||||
// failure, so it is logged at warn level
|
||||
servedBytes, err := io.Copy(w, resp.Content)
|
||||
if err != nil {
|
||||
s.log.Error("failed to write response",
|
||||
s.log.Warn("failed to write response",
|
||||
"request_id", middleware.GetReqID(r.Context()),
|
||||
"host", req.Source.Host,
|
||||
"path", req.Source.Path,
|
||||
"served_bytes", servedBytes,
|
||||
"error", err,
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Log cache status and timing after serving
|
||||
duration := time.Since(startTime)
|
||||
s.log.Info("image served",
|
||||
"request_id", middleware.GetReqID(r.Context()),
|
||||
"cache_key", cacheKey,
|
||||
"cache_key", imagerequest.CacheKey(req),
|
||||
"host", req.Source.Host,
|
||||
"path", req.Source.Path,
|
||||
"cache_status", resp.CacheStatus,
|
||||
"duration_ms", duration.Milliseconds(),
|
||||
"format", req.Transform.Format,
|
||||
|
||||
Reference in New Issue
Block a user