diff --git a/README.md b/README.md index 5268bc1..a643476 100644 --- a/README.md +++ b/README.md @@ -310,6 +310,8 @@ path under `/v1/` answers 200, in maintenance mode too. - 403 when the upstream host, or a host it redirects to, has an address in a blocked network (see `blocked_networks`). - 429 over `image_requests_per_minute` (below). + - 499, with no body, when the client goes away before its image is ready, + unless its request is the one making the image (below). - 415 when the upstream answers with a `Content-Type` that is not one of the source formats (below). - 415 when the upstream sends a body whose first bytes are not those of the @@ -341,6 +343,7 @@ path under `/v1/` answers 200, in maintenance mode too. - 403 - 415 - 429 + - 499, with no body - 502 - 503 - 504 diff --git a/TODO.md b/TODO.md index 92228e4..f00e81f 100644 --- a/TODO.md +++ b/TODO.md @@ -30,6 +30,17 @@ P2: security: per-origin rate limiting # Completed Steps +- 2026-10-26 on both image routes, a request is answered 499 and logged at info + level when its client goes away before its image is ready, unless it is the + request making the image (closes #339). It was answered 500 and logged at + error level, as pixa's own failure. + - The line is "client went away before the image was ready". It carries the + `request_id`, the host and the path. + - The 499 has no body, since nobody reads it. + - `serveImage` answers it before calling `respondImageError`, which cannot + see the request's context. + - An error wrapping `context.Canceled` while the request's context is live + is still answered 500 and logged at error level. - 2026-10-26 `README.md`'s Design section starts with a Concepts list (closes #321). Each concept gets one sentence, under the name the code gives it. - Storage calls the transformed images variants, in shorter sentences. diff --git a/internal/handlers/image.go b/internal/handlers/image.go index 68c2fee..ff11b6e 100644 --- a/internal/handlers/image.go +++ b/internal/handlers/image.go @@ -1,6 +1,7 @@ package handlers import ( + "context" "errors" "fmt" "io" @@ -19,6 +20,12 @@ import ( "sneak.berlin/go/pixa/internal/magic" ) +// statusClientClosedRequest is the status nginx calls "client closed +// request". The image routes answer it when the client goes away before its +// image is ready, unless its request is the one making the image. Nobody reads +// it; it is for logs and metrics. +const statusClientClosedRequest = 499 + // HandleImage handles the main image proxy route: // /v1/image///x., or with no format // /v1/image///x @@ -345,7 +352,8 @@ func notModified(w http.ResponseWriter, r *http.Request, etag string) bool { // 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 +// If-None-Match with 304 and HEAD with the headers only. It answers 499 when +// the image service stopped because the client went away, and any other error // of the image service with respondImageError. func (s *Handlers) serveImage( w http.ResponseWriter, r *http.Request, @@ -362,6 +370,21 @@ func (s *Handlers) serveImage( startTime := time.Now() resp, err := s.imgSvc.Get(r.Context(), req) + + // The client went away before its image was ready, which is not pixa's + // failure. A cancellation while the request's context is live is pixa's, + // and respondImageError answers it 500. + if errors.Is(err, context.Canceled) && r.Context().Err() != nil { + s.log.Info("client went away before the image was ready", + "request_id", middleware.GetReqID(r.Context()), + "host", req.Source.Host, + "path", req.Source.Path, + ) + w.WriteHeader(statusClientClosedRequest) + + return + } + if err != nil { s.respondImageError(w, req, err)