Browse code

daemon/server: don't log context cancelation as error

The [httpstatus.FromError] utility currently maps context cancellation and
deadline errors to a 500 ("internal server error"). As a result, a client
disconnecting would produce an error log:

ERRO[2026-03-11T11:54:39.872784425Z] Handler for POST /v1.54/images/create returned error: failed to resolve reference "docker.io/library/alpine:latest": failed to do request: Head "https://registry-1.docker.io/v2/library/alpine/manifests/latest": context canceled

This patch:

- Checks for context-cancelation on the context, and instead logs an "INFO"
message. For tracing/OTEL, it produces a "499" status code, which is non-
standard, but commonly accepted (and used by NGINX, CloudFront). Given that
no client should be listening anymore, it omits an actual response.
- Adds more structured logs, to align with the logs we produce in our Debug
middleware.

With this patch:

INFO[2026-03-11T16:43:55.567731845Z] API listen on /var/run/docker.sock
INFO[2026-03-11T16:43:58.152254638Z] fetch failed error="failed to do request: Head \"https://registry-1.docker.io/v2/library/alpine/manifests/latest\": context canceled" host=registry-1.docker.io
INFO[2026-03-11T16:43:58.152608805Z] Cancel with lease, leased resources will remain until expiration expires_at="2026-03-12 00:43:57.965872638 +0000 UTC m=+28803.424778128" lease=965878679-Q_SJ
INFO[2026-03-11T16:43:58.152917138Z] request cancelled by client error="failed to resolve reference \"docker.io/library/alpine:latest\": failed to do request: Head \"https://registry-1.docker.io/v2/library/alpine/manifests/latest\": context canceled" method=POST module=api request-url="/v1.54/images/create?fromImage=docker.io%2Flibrary%2Falpine&tag=latest" status=499 vars="map[version:1.54]"

Note some duplication in logs, as some logs are produced by containerd code.

[httpstatus.FromError]: https://github.com/moby/moby/blob/v2.0.0-beta.7/daemon/server/httpstatus/status.go#L44-L45

Signed-off-by: Sebastiaan van Stijn <github@gone.nl>

Sebastiaan van Stijn authored on 2026/03/12 01:37:09
Showing 1 changed files
... ...
@@ -22,6 +22,19 @@ import (
22 22
 // when a request is about to be served.
23 23
 const versionMatcher = "/v{version:[0-9.]+}"
24 24
 
25
+// statusClientClosedRequest (HTTP 499 Client Closed Request) is a non-standard
26
+// HTTP status code used by NGINX to indicate that the client closed the connection
27
+// before the server was able to send a response.
28
+//
29
+// It is not part of the IANA HTTP status code registry and is primarily used
30
+// for logging and telemetry. The client will typically not observe this status,
31
+// as the connection is already closed.
32
+//
33
+// See:
34
+//   - https://developers.cloudflare.com/support/troubleshooting/http-status-codes/4xx-client-error/error-499/
35
+//   - https://nginx.org/en/docs/http/ngx_http_log_module.html
36
+const statusClientClosedRequest = 499
37
+
25 38
 // Server contains instance details for the server
26 39
 type Server struct {
27 40
 	middlewares []middleware.Middleware
... ...
@@ -33,9 +46,9 @@ func (s *Server) UseMiddleware(m middleware.Middleware) {
33 33
 	s.middlewares = append(s.middlewares, m)
34 34
 }
35 35
 
36
-func (s *Server) makeHTTPHandler(r router.Route) http.HandlerFunc {
37
-	handler := r.Handler()
38
-	operation := r.Method() + " " + r.Path()
36
+func (s *Server) makeHTTPHandler(route router.Route) http.HandlerFunc {
37
+	handler := route.Handler()
38
+	operation := route.Method() + " " + route.Path()
39 39
 	return otelhttp.NewHandler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
40 40
 		// Define the context that we'll pass around to share info
41 41
 		// like the docker-request-id.
... ...
@@ -61,10 +74,24 @@ func (s *Server) makeHTTPHandler(r router.Route) http.HandlerFunc {
61 61
 		}
62 62
 
63 63
 		if err := handlerFunc(ctx, w, r, vars); err != nil {
64
-			statusCode := httpstatus.FromError(err)
65
-			if statusCode >= http.StatusInternalServerError {
66
-				log.G(ctx).Errorf("Handler for %s %s returned error: %v", r.Method, r.URL.Path, err)
64
+			if r.Context().Err() != nil {
65
+				// Request is canceled, and client likely went away. Don't attempt
66
+				// to write JSON body, but log for debugging. Log the status as
67
+				// "499 Client Closed Request", which is non-standard, but aligns
68
+				// with NGINX and CloudFlare.
69
+				w.WriteHeader(statusClientClosedRequest) // for OTEL/metrics
70
+				log.G(ctx).WithFields(log.Fields{
71
+					"module":      "api",
72
+					"method":      route.Method(),
73
+					"request-url": r.RequestURI,
74
+					"vars":        vars,
75
+					"error":       err,
76
+					"status":      statusClientClosedRequest,
77
+				}).Info("request cancelled by client")
78
+				return
67 79
 			}
80
+
81
+			statusCode := httpstatus.FromError(err)
68 82
 			// While we no longer support API versions older than 1.24 [config.DefaultMinAPIVersion],
69 83
 			// a client may try to connect using an older version and expect a plain-text error
70 84
 			// instead of a JSON error. This would result in an "API version too old" error
... ...
@@ -79,6 +106,16 @@ func (s *Server) makeHTTPHandler(r router.Route) http.HandlerFunc {
79 79
 					Message: err.Error(),
80 80
 				})
81 81
 			}
82
+			if statusCode >= http.StatusInternalServerError {
83
+				log.G(ctx).WithFields(log.Fields{
84
+					"module":         "api",
85
+					"method":         route.Method(),
86
+					"request-url":    r.RequestURI,
87
+					"vars":           vars,
88
+					"error-response": err,
89
+					"status":         statusCode,
90
+				}).Errorf("Handler for %s %s returned error", route.Method(), route.Path())
91
+			}
82 92
 		}
83 93
 	}), operation).ServeHTTP
84 94
 }