From 727fdb9a7d6306a82ae0e33a11724003cd6c954b Mon Sep 17 00:00:00 2001 From: sneak Date: Mon, 26 Oct 2026 22:46:43 +0000 Subject: [PATCH] Answer 499 to a client that goes away while waiting for an image (closes #339) When a request waits for an image another request is making and its client goes away, the image proxy returns context.Canceled. serveImage now answers it 499, the status nginx calls "client closed request". It logs it once at info level with the request_id, host and path. It was answered 500 and logged at error level, as pixa's own failure. A cancellation while the request's context is live is still answered 500. The request making the image is still answered as before. README.md lists the 499 on both image routes. Model: opus-5-5 --- README.md | 3 +++ TODO.md | 11 +++++++++++ internal/handlers/image.go | 27 +++++++++++++++++++++++++-- 3 files changed, 39 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 5268bc1..0a0865d 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 when the client goes away while waiting for an image another request + is making (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 - 502 - 503 - 504 diff --git a/TODO.md b/TODO.md index 92228e4..99685b0 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 while waiting for an image another request is + making (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..1e91fcf 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 while +// waiting for an image another request is making. 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,8 +352,9 @@ 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 -// of the image service with respondImageError. +// If-None-Match with 304 and HEAD with the headers only. It answers 499 when +// the client goes away while waiting for an image another request is making. +// It answers any other error of the image service with respondImageError. func (s *Handlers) serveImage( w http.ResponseWriter, r *http.Request, req *imagerequest.Request, expires time.Time, @@ -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)