Browse code

client: implement WithResponseHook option

This options allows a client to call hooks on API responses, for
example, to get response header, or other information from the
response.

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

Sebastiaan van Stijn authored on 2026/01/09 22:48:50
Showing 7 changed files
... ...
@@ -59,6 +59,7 @@ import (
59 59
 	"net/http"
60 60
 	"net/url"
61 61
 	"path"
62
+	"slices"
62 63
 	"strings"
63 64
 	"sync"
64 65
 	"sync/atomic"
... ...
@@ -241,6 +242,13 @@ func New(ops ...Opt) (*Client, error) {
241 241
 
242 242
 	c.client.Transport = otelhttp.NewTransport(c.client.Transport, c.traceOpts...)
243 243
 
244
+	if len(cfg.responseHooks) > 0 {
245
+		c.client.Transport = &responseHookTransport{
246
+			base:  c.client.Transport,
247
+			hooks: slices.Clone(cfg.responseHooks),
248
+		}
249
+	}
250
+
244 251
 	return c, nil
245 252
 }
246 253
 
... ...
@@ -2,6 +2,7 @@ package client
2 2
 
3 3
 import (
4 4
 	"context"
5
+	"errors"
5 6
 	"fmt"
6 7
 	"net"
7 8
 	"net/http"
... ...
@@ -55,10 +56,19 @@ type clientConfig struct {
55 55
 	// takes precedence. Either field disables API-version negotiation.
56 56
 	envAPIVersion string
57 57
 
58
+	// responseHooks is a list of custom response hooks to call on responses.
59
+	responseHooks []ResponseHook
60
+
58 61
 	// traceOpts is a list of options to configure the tracing span.
59 62
 	traceOpts []otelhttp.Option
60 63
 }
61 64
 
65
+// ResponseHook is called for each HTTP response returned by the daemon.
66
+// Hooks are invoked in the order they were added.
67
+//
68
+// Hooks must not read or close resp.Body.
69
+type ResponseHook func(*http.Response) error
70
+
62 71
 // Opt is a configuration option to initialize a [Client].
63 72
 type Opt func(*clientConfig) error
64 73
 
... ...
@@ -348,3 +358,18 @@ func WithTraceOptions(opts ...otelhttp.Option) Opt {
348 348
 		return nil
349 349
 	}
350 350
 }
351
+
352
+// WithResponseHook adds a ResponseHook to the client. ResponseHooks are called
353
+// for each HTTP response returned by the daemon. Hooks are invoked in the order
354
+// they were added.
355
+//
356
+// Hooks must not read or close resp.Body.
357
+func WithResponseHook(h ResponseHook) Opt {
358
+	return func(c *clientConfig) error {
359
+		if h == nil {
360
+			return errors.New("invalid response hook: hook is nil")
361
+		}
362
+		c.responseHooks = append(c.responseHooks, h)
363
+		return nil
364
+	}
365
+}
... ...
@@ -2,6 +2,8 @@ package client
2 2
 
3 3
 import (
4 4
 	"crypto/tls"
5
+	"errors"
6
+	"io"
5 7
 	"net/http"
6 8
 	"net/http/cookiejar"
7 9
 	"runtime"
... ...
@@ -390,3 +392,106 @@ func TestWithHTTPClient(t *testing.T) {
390 390
 		cmpopts.IgnoreUnexported(http.Transport{}, tls.Config{}),
391 391
 		cmpopts.EquateComparable(&cookiejar.Jar{}))
392 392
 }
393
+
394
+func TestWithResponseHook(t *testing.T) {
395
+	const hdrKey = "X-Test-Header"
396
+	const hdrVal = "hello-world"
397
+
398
+	t.Run("single hook", func(t *testing.T) {
399
+		var got string
400
+		c, err := New(
401
+			WithResponseHook(func(resp *http.Response) error {
402
+				got = resp.Header.Get(hdrKey)
403
+				return nil
404
+			}),
405
+			WithBaseMockClient(func(req *http.Request) (*http.Response, error) {
406
+				resp := &http.Response{
407
+					StatusCode: http.StatusOK,
408
+					Header:     make(http.Header),
409
+				}
410
+				resp.Header.Set(hdrKey, hdrVal)
411
+				return resp, nil
412
+			}),
413
+		)
414
+		assert.NilError(t, err)
415
+
416
+		_, err = c.Ping(t.Context(), PingOptions{})
417
+		assert.NilError(t, err)
418
+		assert.Check(t, is.Equal(got, hdrVal))
419
+
420
+		assert.NilError(t, c.Close())
421
+	})
422
+
423
+	t.Run("invalid hook", func(t *testing.T) {
424
+		_, err := New(WithResponseHook(nil))
425
+		assert.Error(t, err, "invalid response hook: hook is nil")
426
+	})
427
+
428
+	t.Run("multiple hooks", func(t *testing.T) {
429
+		var triggered []string
430
+
431
+		c, err := New(
432
+			WithResponseHook(func(*http.Response) error {
433
+				triggered = append(triggered, "hook 1: "+hdrVal)
434
+				return nil
435
+			}),
436
+			WithResponseHook(func(*http.Response) error {
437
+				triggered = append(triggered, "hook 2: "+hdrVal)
438
+				return nil
439
+			}),
440
+			WithBaseMockClient(func(req *http.Request) (*http.Response, error) {
441
+				resp := &http.Response{
442
+					StatusCode: http.StatusOK,
443
+					Header:     make(http.Header),
444
+				}
445
+				resp.Header.Set(hdrKey, hdrVal)
446
+				return resp, nil
447
+			}),
448
+		)
449
+		assert.NilError(t, err)
450
+
451
+		_, err = c.Ping(t.Context(), PingOptions{})
452
+		assert.NilError(t, err)
453
+		assert.Check(t, is.DeepEqual(triggered, []string{"hook 1: " + hdrVal, "hook 2: " + hdrVal}))
454
+
455
+		assert.NilError(t, c.Close())
456
+	})
457
+
458
+	t.Run("hook error", func(t *testing.T) {
459
+		closed := false
460
+		expError := errors.New("hook failed")
461
+
462
+		c, err := New(
463
+			WithResponseHook(func(*http.Response) error {
464
+				return expError
465
+			}),
466
+			WithBaseMockClient(func(req *http.Request) (*http.Response, error) {
467
+				return &http.Response{
468
+					StatusCode: http.StatusOK,
469
+					Header:     make(http.Header),
470
+					Body:       &closeTracker{onClose: func() { closed = true }},
471
+				}, nil
472
+			}),
473
+		)
474
+		assert.NilError(t, err)
475
+
476
+		_, err = c.Ping(t.Context(), PingOptions{})
477
+		assert.Check(t, is.ErrorIs(err, expError))
478
+		assert.Check(t, closed)
479
+
480
+		assert.NilError(t, c.Close())
481
+	})
482
+}
483
+
484
+type closeTracker struct {
485
+	onClose func()
486
+}
487
+
488
+func (c *closeTracker) Read(p []byte) (int, error) { return 0, io.EOF }
489
+
490
+func (c *closeTracker) Close() error {
491
+	if c.onClose != nil {
492
+		c.onClose()
493
+	}
494
+	return nil
495
+}
393 496
new file mode 100644
... ...
@@ -0,0 +1,26 @@
0
+package client
1
+
2
+import (
3
+	"net/http"
4
+)
5
+
6
+type responseHookTransport struct {
7
+	base  http.RoundTripper
8
+	hooks []ResponseHook
9
+}
10
+
11
+func (t *responseHookTransport) RoundTrip(req *http.Request) (*http.Response, error) {
12
+	resp, err := t.base.RoundTrip(req)
13
+	if err != nil {
14
+		return resp, err
15
+	}
16
+
17
+	for _, h := range t.hooks {
18
+		if err := h(resp); err != nil {
19
+			_ = resp.Body.Close()
20
+			return nil, err
21
+		}
22
+	}
23
+
24
+	return resp, nil
25
+}
... ...
@@ -59,6 +59,7 @@ import (
59 59
 	"net/http"
60 60
 	"net/url"
61 61
 	"path"
62
+	"slices"
62 63
 	"strings"
63 64
 	"sync"
64 65
 	"sync/atomic"
... ...
@@ -241,6 +242,13 @@ func New(ops ...Opt) (*Client, error) {
241 241
 
242 242
 	c.client.Transport = otelhttp.NewTransport(c.client.Transport, c.traceOpts...)
243 243
 
244
+	if len(cfg.responseHooks) > 0 {
245
+		c.client.Transport = &responseHookTransport{
246
+			base:  c.client.Transport,
247
+			hooks: slices.Clone(cfg.responseHooks),
248
+		}
249
+	}
250
+
244 251
 	return c, nil
245 252
 }
246 253
 
... ...
@@ -2,6 +2,7 @@ package client
2 2
 
3 3
 import (
4 4
 	"context"
5
+	"errors"
5 6
 	"fmt"
6 7
 	"net"
7 8
 	"net/http"
... ...
@@ -55,10 +56,19 @@ type clientConfig struct {
55 55
 	// takes precedence. Either field disables API-version negotiation.
56 56
 	envAPIVersion string
57 57
 
58
+	// responseHooks is a list of custom response hooks to call on responses.
59
+	responseHooks []ResponseHook
60
+
58 61
 	// traceOpts is a list of options to configure the tracing span.
59 62
 	traceOpts []otelhttp.Option
60 63
 }
61 64
 
65
+// ResponseHook is called for each HTTP response returned by the daemon.
66
+// Hooks are invoked in the order they were added.
67
+//
68
+// Hooks must not read or close resp.Body.
69
+type ResponseHook func(*http.Response) error
70
+
62 71
 // Opt is a configuration option to initialize a [Client].
63 72
 type Opt func(*clientConfig) error
64 73
 
... ...
@@ -348,3 +358,18 @@ func WithTraceOptions(opts ...otelhttp.Option) Opt {
348 348
 		return nil
349 349
 	}
350 350
 }
351
+
352
+// WithResponseHook adds a ResponseHook to the client. ResponseHooks are called
353
+// for each HTTP response returned by the daemon. Hooks are invoked in the order
354
+// they were added.
355
+//
356
+// Hooks must not read or close resp.Body.
357
+func WithResponseHook(h ResponseHook) Opt {
358
+	return func(c *clientConfig) error {
359
+		if h == nil {
360
+			return errors.New("invalid response hook: hook is nil")
361
+		}
362
+		c.responseHooks = append(c.responseHooks, h)
363
+		return nil
364
+	}
365
+}
351 366
new file mode 100644
... ...
@@ -0,0 +1,26 @@
0
+package client
1
+
2
+import (
3
+	"net/http"
4
+)
5
+
6
+type responseHookTransport struct {
7
+	base  http.RoundTripper
8
+	hooks []ResponseHook
9
+}
10
+
11
+func (t *responseHookTransport) RoundTrip(req *http.Request) (*http.Response, error) {
12
+	resp, err := t.base.RoundTrip(req)
13
+	if err != nil {
14
+		return resp, err
15
+	}
16
+
17
+	for _, h := range t.hooks {
18
+		if err := h(resp); err != nil {
19
+			_ = resp.Body.Close()
20
+			return nil, err
21
+		}
22
+	}
23
+
24
+	return resp, nil
25
+}