Skip to content

Commit df9e981

Browse files
committed
klauspost for compression
1 parent 464a539 commit df9e981

7 files changed

Lines changed: 191 additions & 2 deletions

File tree

go.mod

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@ module github.com/jcalabro/jttp
22

33
go 1.26
44

5-
require golang.org/x/net v0.53.0
5+
require (
6+
github.com/klauspost/compress v1.18.6
7+
golang.org/x/net v0.53.0
8+
)
69

710
require golang.org/x/text v0.36.0 // indirect

go.sum

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao=
2+
github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
13
golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
24
golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
35
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=

guard.go

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,14 @@
11
package jttp
22

33
import (
4-
"compress/gzip"
54
"context"
65
"fmt"
76
"io"
87
"sync"
98
"sync/atomic"
109
"time"
10+
11+
"github.com/klauspost/compress/gzip"
1112
)
1213

1314
// idleWatchdog cancels a context with a given cause if Reset is not called
@@ -91,6 +92,21 @@ type guardedBodyConfig struct {
9192
minRateWindow time.Duration // window over which to average
9293
decompressGzip bool // wrap inner in gzip.Reader
9394
maxRatio float64 // 0 disables the ratio guard
95+
bodyObserver func(BodyObservation)
96+
observeURL string
97+
statusCode int
98+
observeStarted time.Time
99+
}
100+
101+
// BodyObservation reports response-body byte counts when a guarded response
102+
// body is closed. It is intentionally generic: callers that need
103+
// request-specific labels can derive them from URL.
104+
type BodyObservation struct {
105+
URL string
106+
StatusCode int
107+
CompressedBytes int64
108+
UncompressedBytes int64
109+
Duration time.Duration
94110
}
95111

96112
// guardedBody wraps an http.Response.Body with robustness protections.
@@ -179,6 +195,19 @@ func (g *guardedBody) Close() error {
179195
}
180196
}
181197
g.closeErr = g.inner.Close()
198+
if g.cfg.bodyObserver != nil {
199+
compressed := g.readBytes
200+
if g.compressed != nil {
201+
compressed = g.compressed.total
202+
}
203+
g.cfg.bodyObserver(BodyObservation{
204+
URL: g.cfg.observeURL,
205+
StatusCode: g.cfg.statusCode,
206+
CompressedBytes: compressed,
207+
UncompressedBytes: g.readBytes,
208+
Duration: time.Since(g.cfg.observeStarted),
209+
})
210+
}
182211
})
183212
return g.closeErr
184213
}

guard_test.go

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -461,6 +461,92 @@ func BenchmarkRateTrackerObserveFullWindow(b *testing.B) {
461461
}
462462
}
463463

464+
func BenchmarkGuardedBodyGzipDecode(b *testing.B) {
465+
for _, tc := range []struct {
466+
name string
467+
payload []byte
468+
}{
469+
{name: "repoish", payload: benchmarkRepoishPayload(32 << 20)},
470+
{name: "high_entropy", payload: benchmarkHighEntropyPayload(32 << 20)},
471+
} {
472+
b.Run(tc.name, func(b *testing.B) {
473+
var gzipped bytes.Buffer
474+
zw, err := gzip.NewWriterLevel(&gzipped, gzip.DefaultCompression)
475+
if err != nil {
476+
b.Fatal(err)
477+
}
478+
if _, err := zw.Write(tc.payload); err != nil {
479+
b.Fatal(err)
480+
}
481+
if err := zw.Close(); err != nil {
482+
b.Fatal(err)
483+
}
484+
485+
b.SetBytes(int64(len(tc.payload)))
486+
b.ReportMetric(float64(len(tc.payload))/float64(gzipped.Len()), "ratio")
487+
b.ReportAllocs()
488+
b.ResetTimer()
489+
for i := 0; i < b.N; i++ {
490+
ctx, cancel := context.WithCancelCause(context.Background())
491+
gb, err := newGuardedBody(io.NopCloser(bytes.NewReader(gzipped.Bytes())), guardedBodyConfig{
492+
ctx: ctx,
493+
cancel: cancel,
494+
decompressGzip: true,
495+
maxRatio: 1000,
496+
})
497+
if err != nil {
498+
cancel(nil)
499+
b.Fatal(err)
500+
}
501+
if _, err := io.CopyBuffer(io.Discard, gb, make([]byte, 128<<10)); err != nil {
502+
_ = gb.Close()
503+
cancel(nil)
504+
b.Fatal(err)
505+
}
506+
if err := gb.Close(); err != nil {
507+
cancel(nil)
508+
b.Fatal(err)
509+
}
510+
cancel(nil)
511+
}
512+
})
513+
}
514+
}
515+
516+
func benchmarkRepoishPayload(n int) []byte {
517+
payload := make([]byte, 0, n)
518+
var x uint64 = 0x123456789abcdef0
519+
templates := [][]byte{
520+
[]byte(`{"$type":"app.bsky.feed.post","text":"hello from a busy repo","createdAt":"2026-06-16T18:00:00.000Z"}`),
521+
[]byte(`app.bsky.feed.like/3lq2example cid bafyreigenericrecord did:plc:example`),
522+
[]byte(`app.bsky.graph.follow/3lq2example subject did:plc:subject collection rkey`),
523+
[]byte(`com.atproto.repo.strongRef uri at://did:plc:author/app.bsky.feed.post/3lq2example`),
524+
}
525+
for len(payload) < n {
526+
tpl := templates[len(payload)/4096%len(templates)]
527+
payload = append(payload, tpl...)
528+
for range 64 {
529+
x ^= x << 13
530+
x ^= x >> 7
531+
x ^= x << 17
532+
payload = append(payload, byte(x))
533+
}
534+
}
535+
return payload[:n]
536+
}
537+
538+
func benchmarkHighEntropyPayload(n int) []byte {
539+
payload := make([]byte, n)
540+
var x uint64 = 0x123456789abcdef0
541+
for i := range payload {
542+
x ^= x << 13
543+
x ^= x >> 7
544+
x ^= x << 17
545+
payload[i] = byte(x) ^ byte(i/4096)
546+
}
547+
return payload
548+
}
549+
464550
func gzipZeros(t *testing.T, n int) []byte {
465551
t.Helper()
466552
var buf bytes.Buffer

jttp.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,7 @@ type config struct {
119119
// Response size / decompression
120120
maxResponseBodyBytes int64
121121
maxCompressionRatio float64
122+
bodyObserver func(BodyObservation)
122123

123124
// Redirect safety
124125
allowSchemeDowngrade bool
@@ -301,6 +302,7 @@ func New(opts ...Option) *http.Client {
301302
minRate: cfg.minRate,
302303
minRateWindow: cfg.minRateWindow,
303304
maxRatio: cfg.maxCompressionRatio,
305+
bodyObserver: cfg.bodyObserver,
304306
strictSSRFInitial: cfg.strictSSRFInitial,
305307
redirectGuard: rGuard,
306308
},
@@ -540,6 +542,13 @@ func WithDisableCompression() Option {
540542
return func(c *config) { c.disableCompression = true }
541543
}
542544

545+
// WithBodyObserver registers a callback invoked when a guarded response body
546+
// is closed. This is intended for temporary diagnostics where callers need
547+
// precise response byte counts without changing higher-level APIs.
548+
func WithBodyObserver(fn func(BodyObservation)) Option {
549+
return func(c *config) { c.bodyObserver = fn }
550+
}
551+
543552
// WithForceHTTP2 controls whether HTTP/2 is attempted when a custom TLS
544553
// config is set. Default: true.
545554
func WithForceHTTP2(force bool) Option {

safety.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ type safetyConfig struct {
1919
minRate int64
2020
minRateWindow time.Duration
2121
maxRatio float64
22+
bodyObserver func(BodyObservation)
2223

2324
// SSRF: if true, the safety transport blocks the initial request too
2425
// (the redirectGuard handles subsequent ones via http.Client.CheckRedirect).
@@ -37,6 +38,8 @@ type safetyTransport struct {
3738
}
3839

3940
func (t *safetyTransport) RoundTrip(req *http.Request) (*http.Response, error) {
41+
observeStarted := time.Now()
42+
4043
// Strict SSRF: reject the initial request if target resolves to blocked.
4144
if t.cfg.strictSSRFInitial && t.cfg.redirectGuard != nil {
4245
if err := t.cfg.redirectGuard.checkIPPolicy(req.Context(), req.URL.Hostname()); err != nil {
@@ -106,6 +109,10 @@ func (t *safetyTransport) RoundTrip(req *http.Request) (*http.Response, error) {
106109
minRateWindow: t.cfg.minRateWindow,
107110
decompressGzip: decompressGzip,
108111
maxRatio: t.cfg.maxRatio,
112+
bodyObserver: t.cfg.bodyObserver,
113+
observeURL: req.URL.String(),
114+
statusCode: resp.StatusCode,
115+
observeStarted: observeStarted,
109116
})
110117
if gerr != nil {
111118
// Close the raw body and tear down the watchdogs — otherwise a

safety_test.go

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -214,3 +214,56 @@ func TestSafetyTransportDecodesGzipResponseAndStripsHeader(t *testing.T) {
214214
t.Errorf("decoded = %q, want hello", got)
215215
}
216216
}
217+
218+
func TestSafetyTransportObservesResponseBodyBytesOnClose(t *testing.T) {
219+
// A valid gzip stream for "hello".
220+
gzipHello := []byte{
221+
0x1f, 0x8b, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xff,
222+
0xca, 0x48, 0xcd, 0xc9, 0xc9, 0x07, 0x04, 0x00, 0x00, 0xff, 0xff,
223+
0x86, 0xa6, 0x10, 0x36, 0x05, 0x00, 0x00, 0x00,
224+
}
225+
s := &stubTransport{
226+
body: string(gzipHello),
227+
header: http.Header{
228+
"Content-Encoding": []string{"gzip"},
229+
},
230+
}
231+
232+
var got BodyObservation
233+
st := &safetyTransport{
234+
next: s,
235+
cfg: safetyConfig{
236+
compressionEnabled: true,
237+
bodyObserver: func(obs BodyObservation) {
238+
got = obs
239+
},
240+
},
241+
}
242+
req, _ := http.NewRequestWithContext(context.Background(), http.MethodGet, "http://x/path", http.NoBody)
243+
resp, err := st.RoundTrip(req)
244+
if err != nil {
245+
t.Fatal(err)
246+
}
247+
if _, err := io.ReadAll(resp.Body); err != nil {
248+
t.Fatal(err)
249+
}
250+
if err := resp.Body.Close(); err != nil {
251+
t.Fatal(err)
252+
}
253+
254+
if got.URL != "http://x/path" {
255+
t.Errorf("URL = %q, want request URL", got.URL)
256+
}
257+
if got.StatusCode != http.StatusOK {
258+
t.Errorf("StatusCode = %d, want 200", got.StatusCode)
259+
}
260+
if got.CompressedBytes != int64(len(gzipHello)) {
261+
t.Errorf("CompressedBytes = %d, want %d", got.CompressedBytes, len(gzipHello))
262+
}
263+
if got.UncompressedBytes != int64(len("hello")) {
264+
t.Errorf("UncompressedBytes = %d, want %d", got.UncompressedBytes, len("hello"))
265+
}
266+
if got.Duration <= 0 {
267+
t.Errorf("Duration = %s, want positive", got.Duration)
268+
}
269+
}

0 commit comments

Comments
 (0)