Skip to content

Commit 3853109

Browse files
committed
feat: add retry transport and switch to silent-by-default logging
1 parent 00e48cd commit 3853109

15 files changed

Lines changed: 1499 additions & 61 deletions

File tree

.github/workflows/ci.yml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -114,7 +114,7 @@ jobs:
114114
run: go vet ./...
115115

116116
live-drift:
117-
name: Live ETag drift probe
117+
name: Live drift and retry probe
118118
runs-on: ubuntu-latest
119119
timeout-minutes: 5
120120
needs: test
@@ -130,7 +130,7 @@ jobs:
130130
with:
131131
go-version-file: go.mod
132132
cache: true
133-
- name: go test -tags=live
133+
- name: go test -tags=live (etag + retry)
134134
env:
135135
GITHUB_TOKEN: ${{ github.token }}
136-
run: go test -tags=live -run TestETag_Live ./etag/...
136+
run: go test -tags=live -run 'TestETag_Live|TestRetry_Live' ./etag/... ./retry/...

CHANGELOG.md

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,115 @@ All notable changes to **go-github-kit** are documented in this file.
55
The format is based on [Keep a Changelog 1.1.0](https://keepachangelog.com/en/1.1.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## [1.1.0] - 2026-04-26
9+
10+
Adds a `retry` middleware for transient failures (5xx, network errors,
11+
transport-level deadline exceeded), filling the gap between the rate-limit
12+
layer (which owns 429s) and the underlying transport. Composes with the
13+
existing stack without new runtime dependencies.
14+
15+
### Added
16+
17+
#### Retry middleware (`retry/`)
18+
19+
- New `retry.NewTransport(base, opts...)` chainable `http.RoundTripper`.
20+
Defaults: 3 attempts (1 initial + 2 retries), 200ms..2s decorrelated jitter
21+
(per AWS guidance), idempotent methods only (GET/HEAD/OPTIONS/PUT/DELETE),
22+
default predicate retries {500, 502, 503, 504} or `*net.OpError`/`io.EOF`/
23+
`io.ErrUnexpectedEOF`/`context.DeadlineExceeded`.
24+
- Options: `WithMaxAttempts(n)` (clamped to [1, 100]), `WithBackoff(min, max)`
25+
(clamped to [1ms, 1h] with min<=max), `WithRetryOn(predicate)` (replaces
26+
the default predicate; takes ownership of method-safety),
27+
`WithLogger(*slog.Logger)`.
28+
- Top-level `ghkit.WithRetry(opts ...retry.Option)` slots the layer between
29+
RateLimit and oauth2 in the chain - 429s never reach retry, and retried
30+
requests get the latest token via oauth2's per-call `Source.Token()`.
31+
- 429 hard-exclusion lives outside the predicate so a user-supplied
32+
`WithRetryOn` cannot accidentally fight `ratelimit`.
33+
- `Retry-After` honored (delta-seconds and HTTP-date formats); when it
34+
exceeds the operator's `maxDelay` the call returns
35+
`(prior_resp, retry.ErrRetryAfterExceedsMax)` and the caller owns
36+
drain+close on the response.
37+
- Caller-context cancellation is terminal: `req.Context().Err() != nil`
38+
always stops retries before any predicate is consulted, and during
39+
backoff sleep via `time.NewTimer` + `Stop` (no leaked timers on long
40+
`Retry-After` values).
41+
- Body-bearing retries require `req.GetBody`; missing GetBody on a retry
42+
attempt yields `errors.Join(retry.ErrBodyNotRewindable, prior_err)` so
43+
callers see both causes.
44+
- Exported predicate primitives - `retry.IsIdempotent(method)`,
45+
`retry.IsRetryable5xx(code)`, `retry.IsTransientNetErr(err)` - so callers
46+
composing their own `WithRetryOn` don't have to reimplement the defaults.
47+
- Panic recovery around user predicates: a panicking `WithRetryOn` is
48+
treated as "do not retry" and emits a `retry_predicate_panic` event
49+
rather than crashing the transport.
50+
- Sanitised structured logging via `slog.Logger` with explicit per-event
51+
levels: `retry_sleep`, `retry_decision` (Debug; silent-success first
52+
attempts skipped), `retry_abort`, `retry_body_unrewindable`,
53+
`retry_exhausted`, `retry_predicate_panic` (Warn). `last_err_type` walks
54+
joined/wrapped error chains so operators see meaningful types instead of
55+
`*errors.joinError`.
56+
- DoS protection: prior-response drain capped at 128 KiB before close,
57+
bounding the time we hold a connection on hostile/oversize bodies.
58+
59+
#### Documentation
60+
61+
- README transport-stack diagram updated to show retry between RateLimit
62+
and oauth2.
63+
- New retry recipe with default and tuned-policy examples (including
64+
`Idempotency-Key`-based POST opt-in).
65+
- New "Things worth knowing" note on retry/throttle interaction (each retry
66+
attempt consumes a throttle token).
67+
- Package `doc.go` for `retry/` and updated top-level `doc.go` with the new
68+
chain layout.
69+
70+
#### Tooling and CI
71+
72+
- Live integration test (`retry/live_check_test.go`, build-tag `live`,
73+
function `TestRetry_Live`) that exercises retry against `api.github.com`.
74+
- CI `live-drift` job renamed to `Live drift and retry probe` and extended
75+
to run both `TestETag_Live` and `TestRetry_Live`.
76+
77+
### Changed
78+
79+
- **Silent by default.** `ghkit`, `etag`, `ratelimit`, and `retry` no longer
80+
default-initialise a `slog.Default()` logger. Without an explicit
81+
`WithLogger(...)` call, the library emits no log records. This is a
82+
behaviour change from 1.0.0; users who relied on default stderr output
83+
must now opt in via `ghkit.WithLogger(slog.Default())` (or any logger).
84+
- Per-sub-package `WithLogger` options inside `WithRetry`, `WithETagCache`,
85+
and `WithRateLimit` now correctly override the top-level `WithLogger`
86+
instead of being silently shadowed by it. Previously the chain assembly
87+
appended ghkit's logger after user-supplied sub-package options, so
88+
user values lost; the prepend pattern lets user values win.
89+
- `etag.WithLogger(nil)` and `ratelimit.WithLogger(nil)` now mean
90+
"explicitly silent" instead of being a no-op. Combined with silent-by-
91+
default, this lets callers compose `WithLogger(real)` then
92+
`WithLogger(nil)` to silence on a per-construction basis.
93+
- `retry.IsTransientNetErr` now short-circuits known-permanent failures to
94+
`false`: DNS NXDOMAIN (`*net.DNSError.IsNotFound`), `syscall.ECONNREFUSED`
95+
(TCP RST on connect to a closed port), and x509 cert-validation errors
96+
(`x509.UnknownAuthorityError`, `*x509.HostnameError`,
97+
`x509.CertificateInvalidError`). Misconfigured URLs and expired certs now
98+
fail fast instead of burning the retry budget. Other DNS errors (server
99+
failure, timeout) remain transient.
100+
- `ghkit.WithRateLimit` and `ghkit.WithRateLimitDisabled` are now mutually
101+
exclusive. Combining them returns `ErrConflictingRateLimit` at
102+
construction. Previously the kit logged a warning and silently dropped
103+
the registered callbacks; with silent-by-default that warning would have
104+
been invisible. The hard error matches the existing
105+
`ErrConflictingAuth` precedent for `WithToken` + `WithTokenSource`.
106+
107+
### Added (continued)
108+
109+
- New runnable example at `examples/retry-on-flaky/` demonstrating
110+
`WithRetry` with a tuned backoff and a custom predicate that opts POST in
111+
via `Idempotency-Key`.
112+
113+
### Dependencies
114+
115+
No changes. Same as 1.0.0.
116+
8117
## [1.0.0] - 2026-04-25
9118

10119
Initial public release.
@@ -132,4 +241,5 @@ and rotating PATs alike.
132241
- `golang.org/x/oauth2` v0.36.0
133242
- `golang.org/x/time` v0.15.0
134243

244+
[1.1.0]: https://github.com/pcanilho/go-github-kit/releases/tag/v1.1.0
135245
[1.0.0]: https://github.com/pcanilho/go-github-kit/releases/tag/v1.0.0

README.md

Lines changed: 46 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -51,21 +51,22 @@ func main() {
5151

5252
`ghkit.New` is generic over the returned type; passing `github.NewClient` lets type inference pick up `*github.Client`. ghkit itself has zero dependency on `go-github`. It isn't in `go.mod`, isn't imported, and won't end up in your compiled binary unless you pull it in yourself. Pass whichever go-github major (or any other `func(*http.Client) T` factory) you want.
5353

54-
For runnable starter programs, see [`examples/`](examples/): `static-pat`, `installation-token`, `backfill`, and `github-enterprise` are each a complete `main()` you can copy-paste.
54+
For runnable starter programs, see [`examples/`](examples/): `static-pat`, `installation-token`, `backfill`, `github-enterprise`, and `retry-on-flaky` are each a complete `main()` you can copy-paste.
5555

5656
## How?
5757

5858
```
5959
http.Client
6060
Throttle (x/time/rate proactive) [WithRequestsPerSecond]
6161
RateLimit (go-github-ratelimit v2) [default ON]
62-
oauth2.Transport (clones req, sets Auth) [WithToken/WithTokenSource]
63-
ETag (hashes auth'd clone) [WithETagCache]
64-
Base (*http.Transport,
62+
Retry (5xx + transient net errors) [WithRetry]
63+
oauth2.Transport (clones req, sets Auth) [WithToken/WithTokenSource]
64+
ETag (hashes auth'd clone) [WithETagCache]
65+
Base (*http.Transport,
6566
DisableCompression=true) [WithBaseTransport]
6667
```
6768

68-
Each layer is optional. The stack is opt-in: `ghkit.HTTPClient(...)` only includes the layers you asked for. The order is load-bearing, though. ETag sits below the oauth2 layer so it hashes the request with the current Authorization header. Rate limiting sits above so it sees every outgoing call including ETag-triggered conditional GETs. The proactive throttle sits outermost so it caps issued RPS regardless of cache replays.
69+
Each layer is optional. The stack is opt-in: `ghkit.HTTPClient(...)` only includes the layers you asked for. The order is load-bearing, though. ETag sits below the oauth2 layer so it hashes the request with the current Authorization header. Rate limiting sits above so it sees every outgoing call including ETag-triggered conditional GETs. Retry sits below RateLimit so 429s are deferred to the rate-limit layer; sits above oauth2 so retried requests get the latest token via oauth2's per-call `Source.Token()`. The proactive throttle sits outermost so it caps issued RPS regardless of cache replays.
6970

7071
The rate-limit layer's named options (`WithPrimaryLimitDetected`, `WithSecondaryLimitDetected`, `WithTotalSleepLimit`, `WithLogger`) cover the common callbacks. For upstream features ghkit does not curate, `ratelimit.WithUpstreamOptions(opts ...any)` forwards raw options to `gofri/go-github-ratelimit/v2`.
7172

@@ -183,6 +184,44 @@ gh, err := ghkit.New(func(hc *http.Client) *github.Client {
183184
`WithEnterpriseURLs` requires both URLs to end with a trailing slash and returns an error otherwise. `UserAgent` can also be set at the transport level via `ghkit.WithUserAgent("my-app/1.0")`, which applies to every outbound request regardless of which SDK you wrap around `HTTPClient()`.
184185
</details>
185186

187+
<details>
188+
<summary><b>Retry on transient failures (5xx, network errors)</b></summary>
189+
190+
```go
191+
gh, err := ghkit.New(github.NewClient,
192+
ghkit.WithToken(os.Getenv("GITHUB_TOKEN")),
193+
ghkit.WithRetry(), // 3 attempts, 200ms..2s decorrelated jitter, idempotent methods only
194+
)
195+
```
196+
197+
Tuned policy with POST opt-in via `Idempotency-Key`:
198+
199+
```go
200+
import "github.com/pcanilho/go-github-kit/retry"
201+
202+
gh, err := ghkit.New(github.NewClient,
203+
ghkit.WithToken(token),
204+
ghkit.WithRetry(
205+
retry.WithMaxAttempts(5),
206+
retry.WithBackoff(500*time.Millisecond, 10*time.Second),
207+
retry.WithRetryOn(func(req *http.Request, resp *http.Response, err error) bool {
208+
// Retry POST/PATCH when the caller asserted idempotency.
209+
if req.Header.Get("Idempotency-Key") != "" {
210+
if err != nil { return retry.IsTransientNetErr(err) }
211+
return resp != nil && retry.IsRetryable5xx(resp.StatusCode)
212+
}
213+
// Otherwise the default behaviour.
214+
if !retry.IsIdempotent(req.Method) { return false }
215+
if err != nil { return retry.IsTransientNetErr(err) }
216+
return resp != nil && retry.IsRetryable5xx(resp.StatusCode)
217+
}),
218+
),
219+
)
220+
```
221+
222+
429 is hard-excluded regardless of the predicate so `ratelimit` (the layer above) owns it. `Retry-After` is honored when present; if it exceeds `maxDelay` the call returns `(resp, retry.ErrRetryAfterExceedsMax)` and the caller owns drain+close on `resp`.
223+
</details>
224+
186225
<details>
187226
<summary><b>Use only the etag sub-package in a hand-built stack</b></summary>
188227

@@ -240,6 +279,8 @@ Gzip has to be disabled on the underlying transport, otherwise the hash domain d
240279

241280
`etag.WithKeyScope` is required the moment you share a cache across identities, static PAT or JIT alike. Two callers hitting the same URL under different auth without a scope would race their bodies into the same key, and the library refuses to guess which one wins. Use the installation ID, a per-app scope, or any opaque string.
242281

282+
Each retry attempt is a real HTTP call from the throttle layer's perspective. `WithRetry(retry.WithMaxAttempts(5))` combined with `WithRequestsPerSecond(2, 1)` means a worst-case failing request can briefly use 5x your nominal RPS budget. Size accordingly or leave `maxAttempts` at the default (3).
283+
243284
## Using a different go-github version
244285

245286
The kit has no compile-time pin on `go-github`. Its main `go.mod` does not require `github.com/google/go-github`, so you choose the major. Two equally valid shapes:

doc.go

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,26 @@
1-
// Package ghkit bundles ETag caching, rate limiting, and a proactive token
2-
// bucket behind a single options-pattern API. New is generic over the
3-
// returned client type, so ghkit has no compile-time dependency on any
4-
// specific GitHub SDK; pass any func(*http.Client) T factory at the call
5-
// site (canonically github.com/google/go-github's NewClient).
1+
// Package ghkit bundles ETag caching, rate limiting, retry on transient
2+
// failures, and a proactive token bucket behind a single options-pattern
3+
// API. New is generic over the returned client type, so ghkit has no
4+
// compile-time dependency on any specific GitHub SDK; pass any
5+
// func(*http.Client) T factory at the call site (canonically
6+
// github.com/google/go-github's NewClient).
67
//
78
// Transport stack (outer -> inner, each layer optional):
89
//
910
// http.Client
1011
// UserAgent (overwrites User-Agent) [WithUserAgent]
1112
// Throttle (x/time/rate proactive) [WithRequestsPerSecond]
1213
// RateLimit (go-github-ratelimit v2) [default ON]
13-
// oauth2.Transport (clones req, sets Auth) [WithToken/WithTokenSource]
14-
// ETag (hashes auth'd clone) [WithETagCache]
15-
// Base (*http.Transport,
14+
// Retry (5xx + transient net errors) [WithRetry]
15+
// oauth2.Transport (clones req, sets Auth) [WithToken/WithTokenSource]
16+
// ETag (hashes auth'd clone) [WithETagCache]
17+
// Base (*http.Transport,
1618
// DisableCompression=true) [WithBaseTransport]
1719
//
20+
// Retry sits below RateLimit so 429s are deferred to the rate-limit layer;
21+
// sits above oauth2 so retried requests get the latest token via oauth2's
22+
// per-call Source.Token().
23+
//
1824
// The ETag precompute algorithm is the reason to use this kit. GitHub's
1925
// server-side ETag hash includes the Authorization header, so a passive
2026
// store-and-forward cache falls over under rotating auth (GitHub App

etag/options.go

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
package etag
22

3-
import "log/slog"
3+
import (
4+
"cmp"
5+
"log/slog"
6+
)
47

58
// Option configures a Transport. The interface form (rather than a bare
69
// `func(*config)`) lets us evolve the API without a breaking change: new
@@ -43,9 +46,9 @@ func newConfig(opts []Option) *config {
4346
for _, o := range opts {
4447
o.apply(c)
4548
}
46-
if c.logger == nil {
47-
c.logger = slog.Default()
48-
}
49+
// Silent by default: a nil logger becomes a discard logger so call sites
50+
// can write unconditionally without nil-checks.
51+
c.logger = cmp.Or(c.logger, slog.New(slog.DiscardHandler))
4952
return c
5053
}
5154

@@ -98,13 +101,10 @@ func WithMaxCacheBytes(n int64) Option {
98101
}
99102

100103
// WithLogger supplies the slog.Logger the transport emits events to.
101-
// Default: slog.Default().
104+
// Pass nil (or omit the option) to silence the package; a nil logger is
105+
// replaced with slog.New(slog.DiscardHandler) at construction.
102106
func WithLogger(l *slog.Logger) Option {
103-
return optionFunc(func(cfg *config) {
104-
if l != nil {
105-
cfg.logger = l
106-
}
107-
})
107+
return optionFunc(func(cfg *config) { cfg.logger = l })
108108
}
109109

110110
// WithDriftDetected registers a callback fired on each drift state

examples/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,5 +26,6 @@ When **copying an example into your own project**: drop the `replace` directive
2626
| `installation-token/` | A GitHub App installation token via `oauth2.TokenSource`, with a shared ETag cache scoped per installation. |
2727
| `backfill/` | Backfill / batch jobs: ETag cache + a client-side requests-per-second cap. |
2828
| `github-enterprise/` | Targeting GitHub Enterprise Server via `WithEnterpriseURLs` and a custom user agent. |
29+
| `retry-on-flaky/` | `WithRetry` with a tuned backoff and a custom predicate that opts POST in via `Idempotency-Key`. |
2930

3031
Each example reads its credentials from environment variables (e.g. `GITHUB_TOKEN`, `GITHUB_ENTERPRISE_TOKEN`) and calls `gh.Repositories.Get(...)` as a smoke test. They will fail at the API call without valid credentials. That's expected; they're starting templates, not standalone tools.

examples/retry-on-flaky/main.go

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
// Retry on transient failures: 5xx, EOF, dial errors. Default policy is
2+
// idempotent-only; this example also shows opting POST in via
3+
// Idempotency-Key.
4+
package main
5+
6+
import (
7+
"context"
8+
"fmt"
9+
"log"
10+
"net/http"
11+
"os"
12+
"time"
13+
14+
"github.com/google/go-github/v85/github"
15+
ghkit "github.com/pcanilho/go-github-kit"
16+
"github.com/pcanilho/go-github-kit/retry"
17+
)
18+
19+
func main() {
20+
gh, err := ghkit.New(github.NewClient,
21+
ghkit.WithToken(os.Getenv("GITHUB_TOKEN")),
22+
ghkit.WithRetry(
23+
retry.WithMaxAttempts(5),
24+
retry.WithBackoff(500*time.Millisecond, 10*time.Second),
25+
retry.WithRetryOn(func(req *http.Request, resp *http.Response, err error) bool {
26+
if req.Header.Get("Idempotency-Key") != "" {
27+
if err != nil {
28+
return retry.IsTransientNetErr(err)
29+
}
30+
return resp != nil && retry.IsRetryable5xx(resp.StatusCode)
31+
}
32+
if !retry.IsIdempotent(req.Method) {
33+
return false
34+
}
35+
if err != nil {
36+
return retry.IsTransientNetErr(err)
37+
}
38+
return resp != nil && retry.IsRetryable5xx(resp.StatusCode)
39+
}),
40+
),
41+
)
42+
if err != nil {
43+
log.Fatalf("ghkit.New: %v", err)
44+
}
45+
46+
repo, _, err := gh.Repositories.Get(context.Background(), "google", "go-github")
47+
if err != nil {
48+
log.Fatalf("Repositories.Get: %v", err)
49+
}
50+
fmt.Println(repo.GetFullName())
51+
}

0 commit comments

Comments
 (0)