Repository: moby/moby (language: go)

Issue:
Golang client library - client.ContainerWait returns wrong error when context is canceled
### Description

Given the following function and assuming `cli` is a `*client.Client` we call `WaitForContainer` with a `context.WithCancel(ctx)`.

```go
func WaitForContainer(ctx context.Context, containerID string, timeout time.Duration) (int64, error) {
        waitCtx, cancelWait := context.WithTimeout(ctx, timeout)
	defer cancelWait()

	statusCh, errCh := cli.ContainerWait(waitCtx, containerID, container.WaitConditionNotRunning)
	select {
	case err := <-errCh:
		if err != nil {
			return -1, fmt.Errorf("container exited with an error: %w", err)
		}
	case status := <-statusCh:
		return status.StatusCode, nil
	}
        return -1, nil
}
```

If the context `ctx` is then canceled from the outside, the `errCh` is populated with an empty ( `""`) error and therefor the information that the context has been canceled is lost. 
I added the following to circumvent the problem, but that does not look nice or even reliable IMO.

```go
[...]
                if err != nil {
			if err.Error() == "" {
				// this happens when ContainerWait has been cancelled by the context
				return -1, context.Canceled
			}
			return -1, fmt.Errorf("container exited with an error: %w", err)
		}
[...]
```

The reason for this is that there's no check for context cancelation (or timeout) in `"github.com/docker/docker/client" ContainerWait()` (see [`client/container_wait.go:66`](https://github.com/moby/moby/blob/d7caea206ee1cce1e87aa81c47553bf21cef75ac/client/container_wait.go#L66)). 

### Reproduce

See above.

### Expected behavior

The error returned from the `errCh` should be a `context.Canceled` error.

### docker version

```bash
Client:
 Cloud integration: v1.0.35+desktop.5
 Version:           24.0.6
 API version:       1.43
 Go version:        go1.20.7
 Git commit:        ed223bc
 Built:             Mon Sep  4 12:28:49 2023
 OS/Arch:           darwin/arm64
 Context:           desktop-linux

Server: Docker Desktop 4.24.0 (122432)
 Engine:
  Version:          24.0.6
  API version:      1.43 (minimum version 1.12)
  Go version:       go1.20.7
  Git commit:       1a79695
  Built:            Mon Sep  4 12:31:36 2023
  OS/Arch:          linux/arm64
  Experimental:     false
 containerd:
  Version:          1.6.22
  GitCommit:        8165feabfdfe38c65b599c4993d227328c231fca
 runc:
  Version:          1.1.8
  GitCommit:        v1.1.8-0-g82f18fe
 docker-init:
  Version:          0.19.0
  GitCommit:        de40ad0
```


### docker info

```bash
Client:
 Version:    24.0.6
 Context:    desktop-linux
 Debug Mode: false
 Plugins:
  buildx: Docker Buildx (Docker Inc.)
    Version:  v0.11.2-desktop.5
    Path:     /Users/s/.docker/cli-plugins/docker-buildx
  compose: Docker Compose (Docker Inc.)
    Version:  v2.22.0-desktop.2
    Path:     /Users/s/.docker/cli-plugins/docker-compose
  dev: Docker Dev Environments (Docker Inc.)
    Version:  v0.1.0
    Path:     /Users/s/.docker/cli-plugins/docker-dev
  extension: Manages Docker extensions (Docker Inc.)
    Version:  v0.2.20
    Path:     /Users/s/.docker/cli-plugins/docker-extension
  init: Creates Docker-related starter files for your project (Docker Inc.)
    Version:  v0.1.0-beta.8
    Path:     /Users/s/.docker/cli-plugins/docker-init
  sbom: View the packaged-based Software Bill Of Materials (SBOM) for an image (Anchore Inc.)
    Version:  0.6.0
    Path:     /Users/s/.docker/cli-plugins/docker-sbom
  scan: Docker Scan (Docker Inc.)
    Version:  v0.26.0
    Path:     /Users/s/.docker/cli-plugins/docker-scan
  scout: Docker Scout (Docker Inc.)
    Version:  v1.0.7
    Path:     /Users/s/.docker/cli-plugins/docker-scout

Server:
 Containers: 81
  Running: 12
  Paused: 0
  Stopped: 69
 Images: 74
 Server Version: 24.0.6
 Storage Driver: overlay2
  Backing Filesystem: extfs
  Supports d_type: true
  Using metacopy: false
  Native Overlay Diff: true
  userxattr: false
 Logging Driver: json-file
 Cgroup Driver: cgroupfs
 Cgroup Version: 2
 Plugins:
  Volume: local
  Network: bridge host ipvlan macvlan null overlay
  Log: awslogs fluentd gcplogs gelf journald json-file local logentries splunk syslog
 Swarm: inactive
 Runtimes: io.containerd.runc.v2 runc
 Default Runtime: runc
 Init Binary: docker-init
 containerd version: 8165feabfdfe38c65b599c4993d227328c231fca
 runc version: v1.1.8-0-g82f18fe
 init version: de40ad0
 Security Options:
  seccomp
   Profile: unconfined
  cgroupns
 Kernel Version: 6.4.16-linuxkit
 Operating System: Docker Desktop
 OSType: linux
 Architecture: aarch64
 CPUs: 11
 Total Memory: 11.68GiB
 Name: docker-desktop
 ID: 2e53878e-fa7e-4736-b79e-3b1207b4543f
 Docker Root Dir: /var/lib/docker
 Debug Mode: false
 HTTP Proxy: http.docker.internal:3128
 HTTPS Proxy: http.docker.internal:3128
 No Proxy: hubproxy.docker.internal
 Experimental: false
 Insecure Registries:
  hubproxy.docker.internal:5555
  127.0.0.0/8
 Live Restore Enabled: false

WARNING: daemon is not using the default seccomp profile
```


### Additional Info

_No response_


Discussion / hints:
Would you like to make a PR to have the error handling at https://github.com/moby/moby/blob/d7caea206ee1cce1e87aa81c47553bf21cef75ac/client/container_wait.go#L66 check for a context error? 
Sure, I can try 😀 I'll do my very best. 

Disclaimer: never contributed to `moby/moby`, so please be patient 😂
@cpuguy83 Which branch should I target? `master` or `24.0`? Or how do you manage branches and PRs on this project?
Master and then after it is merged we can discuss backports.

Fix patch:
diff --git a/client/container_wait.go b/client/container_wait.go
index dd866cd0d01eb..b8d3bdef0db8e 100644
--- a/client/container_wait.go
+++ b/client/container_wait.go
@@ -72,8 +72,12 @@ func (cli *Client) ContainerWait(ctx context.Context, containerID string, condit
 			//
 			// If there's a JSON parsing error, read the real error message
 			// off the body and send it to the client.
-			_, _ = io.ReadAll(io.LimitReader(stream, containerWaitErrorMsgLimit))
-			errC <- errors.New(responseText.String())
+			if errors.As(err, new(*json.SyntaxError)) {
+				_, _ = io.ReadAll(io.LimitReader(stream, containerWaitErrorMsgLimit))
+				errC <- errors.New(responseText.String())
+			} else {
+				errC <- err
+			}
 			return
 		}
 


Test patch:
diff --git a/client/container_wait_test.go b/client/container_wait_test.go
index e7fc7e1a0cf37..7cbfc72d20bfc 100644
--- a/client/container_wait_test.go
+++ b/client/container_wait_test.go
@@ -9,11 +9,14 @@ import (
 	"log"
 	"net/http"
 	"strings"
+	"syscall"
 	"testing"
+	"testing/iotest"
 	"time"
 
 	"github.com/docker/docker/api/types/container"
 	"github.com/docker/docker/errdefs"
+	"github.com/pkg/errors"
 	"gotest.tools/v3/assert"
 	is "gotest.tools/v3/assert/cmp"
 )
@@ -117,6 +120,46 @@ func TestContainerWaitProxyInterruptLong(t *testing.T) {
 	}
 }
 
+func TestContainerWaitErrorHandling(t *testing.T) {
+	for _, test := range []struct {
+		name string
+		rdr  io.Reader
+		exp  error
+	}{
+		{name: "invalid json", rdr: strings.NewReader(`{]`), exp: errors.New("{]")},
+		{name: "context canceled", rdr: iotest.ErrReader(context.Canceled), exp: context.Canceled},
+		{name: "context deadline exceeded", rdr: iotest.ErrReader(context.DeadlineExceeded), exp: context.DeadlineExceeded},
+		{name: "connection reset", rdr: iotest.ErrReader(syscall.ECONNRESET), exp: syscall.ECONNRESET},
+	} {
+		t.Run(test.name, func(t *testing.T) {
+			ctx, cancel := context.WithCancel(context.Background())
+			defer cancel()
+
+			client := &Client{
+				version: "1.30",
+				client: newMockClient(func(req *http.Request) (*http.Response, error) {
+					return &http.Response{
+						StatusCode: http.StatusOK,
+						Body:       io.NopCloser(test.rdr),
+					}, nil
+				}),
+			}
+			resultC, errC := client.ContainerWait(ctx, "container_id", "")
+			select {
+			case err := <-errC:
+				if err.Error() != test.exp.Error() {
+					t.Fatalf("ContainerWait() errC = %v; want %v", err, test.exp)
+				}
+				return
+			case result := <-resultC:
+				t.Fatalf("expected to not get a wait result, got %d", result.StatusCode)
+				return
+			}
+			// Unexpected - we should not reach this line
+		})
+	}
+}
+
 func ExampleClient_ContainerWait_withTimeout() {
 	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
 	defer cancel()