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>
| ... | ... |
@@ -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 |
} |