Releases: pcanilho/go-github-kit
v1.6.0
Adds two complementary observability surfaces to etag.Transport and
removes the single-purpose WithDriftDetected callback (BREAKING). Drift
transitions are now delivered via the unified WithEventCallback hook
as KindDriftDetected / KindDriftRecovered events; the full
DriftEvent payload remains available on evt.DriftEvent.
Added
etag.Statsgains four per-Outcome counters:TotalHits,
TotalMisses,TotalStores,TotalBypasses. All atomic, monotonic
over the Transport's lifetime, read viaStats()without taking the
driftMu mutex (the existing brief lock for{Degraded, DegradedAt}
consistency is unchanged). Lets consumers compute hit-rate /
store-rate / bypass-rate by pollingStats()at any cadence, without
bumping the slog handler toLevelDebugto scrapeetag_eventrecords
on the hot path.TotalBypassesaggregates all four uncached
pass-through paths (bypass_oversize,bypass_noncacheable, both
no_etag_headersites). Rare error and invalidation outcomes
(get_error,store_error,remove_error,invalidated_gone) remain
observable via slog at INFO/WARN level.etag.WithEventCallback(cb func(ctx context.Context, evt etag.Event))
for per-call attribution. The callback fires on every cache decision,
validation outcome, store/invalidation, and drift transition with the
request URL, normalised path template, and Kind-specific fields.etag.Eventstruct andetag.Kindtype with 14 constants matching
the existing slogkindattribute values. Drift kinds drop the
etag_prefix that the slog kind attributes carry, for naming
consistency with the bareetag_eventkinds.
Removed (BREAKING)
etag.WithDriftDetected(cb func(DriftEvent)) Option. Use
etag.WithEventCallback(cb)and filter on
evt.Kind == etag.KindDriftDetected || evt.Kind == etag.KindDriftRecovered.
The fullDriftEventpayload remains onevt.DriftEvent. See
MIGRATION.md "v1.6: per-call event attribution" for a copy-paste swap.- Slog event
drift_callback_panic. The previous version used a
recover()guard around the drift callback; v1.6 does not catch
panics fromWithEventCallback, so the corresponding slog event no
longer fires. Panics propagate up throughRoundTrip.
Documentation
MIGRATION.mdgains a "v1.6: per-call event attribution" recipe
showingWithEventCallbackctx propagation, repo extraction from
Event.URL, and the per-page callback fan-out under thepages
package.README.mdline 503 now referencesStats's per-Outcome counters
and theWithEventCallbackhook.PROPOSALS.mdupdates the observability-roadmap row to mark
per-call event callbacks as Implemented.- Godoc on the
Transporttype (etag/transport.go:90-91) and on
theStatsstruct (etag/drift.go:55-56) updated to reference
the new surfaces. - Slog event allowlist comment at
etag/transport.go:3-18updated:
drift_callback_panicremoved (the recover guard is gone). etag/drift.go:1-9package prologue updated: drift transitions are
observable viaWithEventCallback.
v1.5.0
Adds three consumer-utility sub-packages that ride on top of the
assembled *http.Client and compose with the existing transport
stack: polling (range-over-func iterator over an HTTP endpoint on
an interval), search (envelope iterator for /search/* endpoints
that pages.As[T] cannot serve), and cond (visible 304 / change-
vs-unchanged signal building on the etag layer). Hardens retry's
Retry-After parser as part of the same release.
Fixed
retry:Retry-Afterparser saturates extremely large delta-seconds
values (abovemath.MaxInt64 / int64(time.Second)) instead of wrapping
int64 nanoseconds. Clamped values route through the existing
ErrRetryAfterExceedsMaxabort path.retry: whitespace aroundRetry-Aftervalues (" 5","5 ") is now
trimmed before parsing.
Added
polling.Pollandpolling.As[T]: Go 1.23 range-over-func iterator
that re-issues a request on a caller-tunable interval, reusing the
supplied*http.Clientso retry, etag, ratelimit, throttle, and
oauth2 apply per attempt. Options:WithDone(header/status
predicate),WithDoneT[T](typed predicate on the decoded value),
WithDecode[T](custom decoder),WithChangeOnly(skip yields on
cache hit),WithMaxAttempts,WithMaxWallClock,WithJitter
(deterministic mid-point),WithHonorRetryAfter,WithLogger,
plusWithSleepFunc/WithNowFunctest seams. Sentinels
ErrMaxAttemptsExceeded,ErrMaxWallClockExceeded(wraps
context.DeadlineExceeded),ErrInvalidInterval,
ErrInvalidOption,ErrPredicatePanic. Boundary yields surface
(lastResp, sentinel)so the caller can inspect what triggered
the limit.search.Issues,search.Code,search.Repos,search.Users:
typed iterators over GitHub's/search/*envelope endpoints.
YieldsResult[T]{Item, TotalCount, IncompleteResults}per item;
surfaces the post-page-10 1000-result hard cap as
ErrResultCapHit. Reusespages.Pagesfor Link-header walking.
Functional options (WithBaseURL,WithPerPage,WithSort,
WithOrder,WithHeaders) match the convention used by the rest
of ghkit;WithBaseURLoverrides the GitHub default for GHES or
test fixtures.cond.Status,cond.StatusOf,cond.Fetch[T]: surfaces the
change-vs-unchanged signal the etag layer already computes.
cond.HeaderCacheStatusis the canonical"X-Ghkit-Cache"header
the etag layer sets on synth-200 ("hit") and wire-200 store
("miss").StatusOfis nil-safe and maps absent toUpdated.
Sentinel errorscond.ErrNilClient,cond.ErrNilContext,
cond.ErrNilRequestfor invalidFetcharguments. Decode errors
wrap ascond: decode: %wfor parity with pages, polling, search.etag/transport.go: setscond.HeaderCacheStatuson synth-200
(after header merge) and wire-200 (aftercache.Addreturns) so
the cache does not ingest the key. Drift detector and
ComputeExpectedETagare unaffected (the header is response-only
and not in the hash domain).retry.RetryAfter(resp) (time.Duration, bool): exported wrapper
aroundparseRetryAfter. Returns(0, false)for absent,
unparseable, AND negative-numeric values. Used bypollingto
honor server hints without duplicating the parser.ghtest.ETagServer(t, body) (*httptest.Server, *int64): lifted
frometag/transport_test.go. Used by polling, cond, and any
future test that exercises etag composition.retry:retry_retry_after_unparseableslog event at Warn when
the header is present but matches neither delta-seconds nor
HTTP-date. Carries a length-bounded, ASCII-sanitisedraw
attribute (32 bytes max).retry:retry_sleepevent'ssourceattribute gains a
"malformed"value, distinct from"jitter"and"retry_after".retry:FuzzParseRetryAfterfuzz test pinning the parser as
total.
Documentation
- New
polling/doc.go,search/doc.go,cond/doc.godocumenting
contracts and sharp edges (retry compounding, throttle backpressure,
etag synth-200 sameness, gofri ctx-cancel observed on next
round-trip, header set ordering for the cond signal). retry/doc.godocuments the unparseable arm and the
source="malformed"label.pages/doc.go: corrected the stale chain-order note (predates the
v1.4.0 ratelimit/throttle inversion).README.md: Go Report Card badge added; coverage badge skipped
(peer cohort split: 3 of 4 transport-utility libraries do not
carry one).
Examples
- New
examples/poll-workflow-run/: waits for a workflow run to
reachstatus="completed"viapolling.As[*github.WorkflowRun]
withWithDoneT,WithMaxWallClock,WithJitter. Pairs polling
withWithETagCacheso unchanged ticks short-circuit at the etag
layer. - New
examples/search-issues/: walks/search/issueswith
search.Issues[*github.Issue], surfacesincomplete_resultsand
the 1000-result cap. - New
examples/conditional-fetch/: visible 304 via
cond.Fetch[*github.Repository]; the second call returns
cond.Unchangedso downstream work can be skipped.
Tooling
polling,search, andcondintroduce no new runtime
dependencies in the root module. Theghtest.ETagServerlift adds
no module surface;condis stdlib-only;pollingimportscond
andretry;searchimportspages. The import graph remains
acyclic (etag -> cond,polling -> cond, retry,search -> pages).
v1.4.0
Adds a pages sub-package: a Go 1.23 range-over-func iterator for
Link-header pagination, plus a typed wrapper that decodes each page
into elements of any type. The iterator runs on a caller-supplied
*http.Client, so the existing RateLimit, Throttle, Retry, oauth2,
and ETag layers apply per page with no extra wiring. Also extends
ghtest with a LinkHeader fixture builder so tests can construct
RFC 8288 Link headers without rolling their own. No new runtime
dependencies in the root module.
Added
pages.Pages(ctx, client, method, url, headers): range-over-func
iterator over paginated REST responses. WalksLink: rel="next"
and yields each*http.Response. Caller drains and closes each
body. Errors stop iteration after one yield; a clean end of
pagination (norel="next") stops silently.pages.As[T any](ctx, client, method, url, headers): typed wrapper
that decodes each page into[]Twith the standard library JSON
decoder and yields elements one at a time. Iterator owns each
response body and closes it after decoding, including on caller
break and on context cancellation.Thas no constraint, so
*github.Repositoryand similar SDK types work without
implementingjson.Unmarshaler.pages.ErrInvalidLinkHeader: exported sentinel surfaced when a
response Link header is structurally malformed. A missing
rel="next"is treated as a clean end of pagination, not an error.ghtest.LinkHeader(baseURL, page, perPage, lastPage): builds an
RFC 8288 Link header value for fixture servers. Returns "" when
lastPage <= 1so handlers can branch on a single string. Pairs
withghtest.Write304IfMatchandghtest.WriteSecondaryLimit.
Documentation
doc.goadds a paragraph naming thepagessub-package alongside
the existingetag,ratelimit, andthrottlenotes.README.mdadds an "Iterating over paginated results" recipe under
the Recipes section, with a runnable snippet usingpages.Asplus
WithETagCache.TESTING.mdreplaces the manual Link-header pagination recipe with
one that usesghtest.LinkHeader, mirroring how the
secondary-rate-limit recipe referencesghtest.WriteSecondaryLimit.examples/README.mdadds thelist-all-repos/row to the table.
Examples
- New
examples/list-all-repos/: usespages.As[*github.Repository]
to walk/user/reposagainstapi.github.comend to end. Shows
the iterator composing withWithETagCacheandWithUserAgent.
Dependencies
No changes in the root module. The examples/ module is unchanged.
Tooling
.golangci.yaml:bodycloseadded to the test-file linter
exclusion list. The iter.Seq2 yield-then-close pattern in
pages_test.goproduces false positives the linter cannot trace
through; the suppression is scoped to_test.gopaths so production
code still gets full bodyclose coverage. The two//nolint:bodyclose
directives inpages/pages.goremain (caller-closes contract on
yielded responses; close-via-decodePage on the typed wrapper).
v1.3.1
Inverts the throttle/ratelimit chain so RateLimit wraps Throttle.
Pre-1.4 ordering let throttle keep admitting new requests during a
gofri secondary cooldown; at cooldown end the parked requests
stampeded the server simultaneously, re-tripping the abuse detector.
Inverted ordering parks new arrivals inside gofri's waitForRateLimit
before they consume throttle tokens, so post-cooldown release is
bounded by the throttle burst.
Changed
- Transport stack:
ratelimitnow wrapsthrottleinstead of the
inverse. Callers using only one of the two layers see no behavior
change. Logger event ordering is inverted as a side effect: throttle
events now emit inside ratelimit's RoundTrip span rather than
wrapping it; observability dashboards keying on the legacy ordering
need adjustment.
v1.3.0
Adds etag.WithAutoKeyScope so a single *http.Client can serve
multiple tenants without provisioning N transports. Elevates GraphQL/v4
to first-class billing in the docs: the generic factory already worked
with shurcooL/githubv4, but the lead documentation framed the kit as
a google/go-github (REST) wrapper. No new runtime dependencies in
the root module.
Added
etag.WithAutoKeyScope(fn func(*http.Request) (string, error)): derives
the cache-key scope per request. Mutually exclusive with
etag.WithKeyScope; combining both yieldsetag.ErrConflictingScope.
Either option satisfies the caller-supplied-Cachescope requirement.etag.ErrEmptyScope: sentinel wrapped and returned fromRoundTrip
when the fn returns an empty string with a nil error. The contract is
"non-empty scope or non-nil error"; the empty + nil combination is a
programming error, not a bypass signal.etag.ErrConflictingScope: sentinel surfaced at construction when both
WithKeyScopeandWithAutoKeyScopeare set.
Documentation
doc.golead paragraph rewritten to name bothgoogle/go-github(REST)
andshurcooL/githubv4(GraphQL) as canonical factories. New
paragraphs document GraphQL/v4 compatibility (etag layer no-ops on
POST; v4 traffic flows through oauth2 + retry + ratelimit + throttle- UA without ETag caching) and custom cache backends via the
three-methodetag.Cacheinterface.
- UA without ETag caching) and custom cache backends via the
README.mdlead paragraph elevates GraphQL/v4 to first-class billing.
New recipes: "Recommended setup for a long-lived service",
"GraphQL with shurcooL/githubv4", "Multi-tenant single client (one
Transport, many installations)".MIGRATION.mdRecipe 2 cross-referencesWithAutoKeyScopefor the
multi-installation single-transport pattern.
Examples
examples/installation-token/main.goreplaces the
oauth2.StaticTokenSourcestand-in with an inline
bradleyfalzon/ghinstallation/v2tooauth2.TokenSourceadapter
(the canonical local-key JWT signing path).- New
examples/graphql-v4/runs a minimalViewer.Loginquery through
shurcooL/githubv4over a ghkit-built*http.Client. Pins compile-time
compatibility with the v4 SDK in the examples CI lane.
Dependencies
No changes in the root module. The examples/ module gains
bradleyfalzon/ghinstallation/v2, golang-jwt/jwt/v4 (transitive),
shurcooL/githubv4, and shurcooL/graphql (transitive).
v1.2.1
Three bug fixes in transport behaviour and a small set of godoc clarifications.
No API surface changes, no new dependencies.
Fixed
retry.Transport: whenRetry-Afterexceeds the configuredmaxDelay,
the prior 5xx response is now drained and closed inside the transport and
the call returns(nil, ErrRetryAfterExceedsMax). Previously the
transport returned(resp, err)withresp.Bodyopen, which violated
thehttp.RoundTrippercontract that a non-nil error implies the caller
has nothing to close. When wrapped byhttp.Client(the standard
ghkit.Newpath), the response was dropped unclosed, leaking the body
and preventing connection reuse on every aborted retry.etag.Transportdrift recovery: the post-cooldown probe predicate
shifted fromn%driftProbeEveryN == 0ton%driftProbeEveryN == 1.
Probes now fire on calls 1, 51, 101 afterdriftCooldownelapses (was
50, 100, 150), shifting the probe burst to the start of the post-cooldown
window and cutting recovery latency from 150 to 101 cacheable requests.etag.NewLRUCache:byteTotalaccounting now decrements on count-cap
evictions via a registered eviction callback. Previously, when the LRU's
internal slot cap (default 4096) overflowed and the eldest entry was
evicted,byteTotalwas not adjusted; on workloads with more than 4096
distinct cached entries combined withWithMaxCacheBytes, the
over-counted total made the byte-budget loop evict real entries to
compensate for phantom bytes, shrinking effective cache capacity.
Documentation
retry.ErrRetryAfterExceedsMaxgodoc records the new contract: on this
errorrespisniland the transport has already drained and closed
the prior response.etag.WithKeyScopegodoc clarifies that an empty scope is treated
identically to omitting the option, and that combiningWithCachewith
no scope fails construction withErrKeyScopeRequired.ratelimit.WithUpstreamOptionsgodoc warns that type-mismatched values
are silently dropped by the upstream constructor.etagpackage doc records the drift detector's tuning assumption: a
transport handling fewer than roughly 100 cacheable requests after the
cooldown window elapses will not complete recovery under the current
private thresholds.ghkit.HTTPClientcomposition-order comment now lists theretrylayer
betweenoauth2andratelimit, matching the actual code.- README retry recipe updated to match the new abort contract and to note
that POST/PATCH retries with a body requirereq.GetBody.
v1.2.0
Adds a ghtest sub-package with two helpers that hide the non-discoverable
GitHub-specific traps in testing ghkit-using code: secondary-rate-limit
classification (the documentation_url suffix gofri/go-github-ratelimit
pattern-matches on) and the bored-engineer ETag hash domain (which includes
the request Authorization header). Plus a TESTING.md with self-contained
recipes for the surrounding stdlib bits. No new runtime dependencies.
Added
ghtest sub-package
ghtest.WriteSecondaryLimit(w, retryAfter)writes a 403 withRetry-After
(whole seconds; negative durations clamp to zero) and a JSON body whose
documentation_urlends in#secondary-rate-limits. That suffix is what
gofri/go-github-ratelimitpattern-matches on to classify the response as
anAbuseRateLimitError, so consumer retry paths actually trigger in
tests instead of silently falling through.ghtest.Write304IfMatch(w, r, body) boolcomputes the expected ETag using
the bored-engineer algorithm (hashes Authorization, Accept, and Cookie
request headers along with the body), normalises every tag in
If-None-Match(split on commas, trim whitespace, stripW/weak prefix
and surrounding quotes), and on any match sets a quoted RFC 7232ETag
response header, writes 304 Not Modified with empty body, and returns
true. Returns false and writes nothing on miss.- Both helpers compose with stdlib
httptest.NewServer; consumers bring
their own handler. Tests cover quoted/unquoted/weak-only matches, multi-tag
comma splitting, no-match leaves response untouched, no-header
short-circuit, and different-body misses.
Documentation
- New
TESTING.mdwith self-contained recipes: routing a ghkit-built client
at a test server (thegh.BaseURL = url.Parse(srv.URL+"/")pattern),
ETag 304 replay handling, rate-limit header emission (no helper, recipe
only), Link-header pagination (no helper, recipe only), secondary-rate-
limit testing, plus a "See also" pointer tomigueleliasweb/go-github-mock
for the SDK-layer mocking case. - README "Testing your code" section, three lines linking to
TESTING.md.
Dependencies
No changes. Same as 1.1.0.
v1.1.0
Adds a retry middleware for transient failures (5xx, network errors,
transport-level deadline exceeded), filling the gap between the rate-limit
layer (which owns 429s) and the underlying transport. Composes with the
existing stack without new runtime dependencies.
Added
Retry middleware (retry/)
- New
retry.NewTransport(base, opts...)chainablehttp.RoundTripper.
Defaults: 3 attempts (1 initial + 2 retries), 200ms..2s decorrelated jitter
(per AWS guidance), idempotent methods only (GET/HEAD/OPTIONS/PUT/DELETE),
default predicate retries {500, 502, 503, 504} or*net.OpError/io.EOF/
io.ErrUnexpectedEOF/context.DeadlineExceeded. - Options:
WithMaxAttempts(n)(clamped to [1, 100]),WithBackoff(min, max)
(clamped to [1ms, 1h] with min<=max),WithRetryOn(predicate)(replaces
the default predicate; takes ownership of method-safety),
WithLogger(*slog.Logger). - Top-level
ghkit.WithRetry(opts ...retry.Option)slots the layer between
RateLimit and oauth2 in the chain - 429s never reach retry, and retried
requests get the latest token via oauth2's per-callSource.Token(). - 429 hard-exclusion lives outside the predicate so a user-supplied
WithRetryOncannot accidentally fightratelimit. Retry-Afterhonored (delta-seconds and HTTP-date formats); when it
exceeds the operator'smaxDelaythe call returns
(prior_resp, retry.ErrRetryAfterExceedsMax)and the caller owns
drain+close on the response.- Caller-context cancellation is terminal:
req.Context().Err() != nil
always stops retries before any predicate is consulted, and during
backoff sleep viatime.NewTimer+Stop(no leaked timers on long
Retry-Aftervalues). - Body-bearing retries require
req.GetBody; missing GetBody on a retry
attempt yieldserrors.Join(retry.ErrBodyNotRewindable, prior_err)so
callers see both causes. - Exported predicate primitives -
retry.IsIdempotent(method),
retry.IsRetryable5xx(code),retry.IsTransientNetErr(err)- so callers
composing their ownWithRetryOndon't have to reimplement the defaults. - Panic recovery around user predicates: a panicking
WithRetryOnis
treated as "do not retry" and emits aretry_predicate_panicevent
rather than crashing the transport. - Sanitised structured logging via
slog.Loggerwith explicit per-event
levels:retry_sleep,retry_decision(Debug; silent-success first
attempts skipped),retry_abort,retry_body_unrewindable,
retry_exhausted,retry_predicate_panic(Warn).last_err_typewalks
joined/wrapped error chains so operators see meaningful types instead of
*errors.joinError. - DoS protection: prior-response drain capped at 128 KiB before close,
bounding the time we hold a connection on hostile/oversize bodies.
Documentation
- README transport-stack diagram updated to show retry between RateLimit
and oauth2. - New retry recipe with default and tuned-policy examples (including
Idempotency-Key-based POST opt-in). - New "Things worth knowing" note on retry/throttle interaction (each retry
attempt consumes a throttle token). - Package
doc.goforretry/and updated top-leveldoc.gowith the new
chain layout.
Tooling and CI
- Live integration test (
retry/live_check_test.go, build-taglive,
functionTestRetry_Live) that exercises retry againstapi.github.com. - CI
live-driftjob renamed toLive drift and retry probeand extended
to run bothTestETag_LiveandTestRetry_Live.
Changed
- Silent by default.
ghkit,etag,ratelimit, andretryno longer
default-initialise aslog.Default()logger. Without an explicit
WithLogger(...)call, the library emits no log records. This is a
behaviour change from 1.0.0; users who relied on default stderr output
must now opt in viaghkit.WithLogger(slog.Default())(or any logger). - Per-sub-package
WithLoggeroptions insideWithRetry,WithETagCache,
andWithRateLimitnow correctly override the top-levelWithLogger
instead of being silently shadowed by it. Previously the chain assembly
appended ghkit's logger after user-supplied sub-package options, so
user values lost; the prepend pattern lets user values win. etag.WithLogger(nil)andratelimit.WithLogger(nil)now mean
"explicitly silent" instead of being a no-op. Combined with silent-by-
default, this lets callers composeWithLogger(real)then
WithLogger(nil)to silence on a per-construction basis.retry.IsTransientNetErrnow short-circuits known-permanent failures to
false: DNS NXDOMAIN (*net.DNSError.IsNotFound),syscall.ECONNREFUSED
(TCP RST on connect to a closed port), and x509 cert-validation errors
(x509.UnknownAuthorityError,*x509.HostnameError,
x509.CertificateInvalidError). Misconfigured URLs and expired certs now
fail fast instead of burning the retry budget. Other DNS errors (server
failure, timeout) remain transient.ghkit.WithRateLimitandghkit.WithRateLimitDisabledare now mutually
exclusive. Combining them returnsErrConflictingRateLimitat
construction. Previously the kit logged a warning and silently dropped
the registered callbacks; with silent-by-default that warning would have
been invisible. The hard error matches the existing
ErrConflictingAuthprecedent forWithToken+WithTokenSource.
Added (continued)
- New runnable example at
examples/retry-on-flaky/demonstrating
WithRetrywith a tuned backoff and a custom predicate that opts POST in
viaIdempotency-Key.
Dependencies
No changes. Same as 1.0.0.
v1.0.0
Initial public release.
go-github-kit packages three things most projects re-implement on top of
google/go-github - a conditional-request
ETag cache, the well-known reactive rate limiter from gofri/go-github-ratelimit,
and a client-side token-bucket throttle - behind one options-pattern constructor.
You can adopt the whole stack in a few lines, or import the sub-packages a la
carte if you already have one of these and just want the others.
The headline feature is the ETag layer: GitHub's server-side ETag includes the
Authorization header, so a passive store-and-forward cache loses its hit rate
the moment your token rotates. ghkit reproduces that hash client-side so cached
entries keep working across rotations - durable quota savings for GitHub Apps
and rotating PATs alike.
Added
ETag caching that survives token rotation (etag/)
- Client-side precompute of GitHub's auth-inclusive ETag hash, so 304s keep
flowing across GitHub App installation-token rotations and rotating PATs.
Algorithm originally reverse-engineered by
bored-engineer. - Bounded in-process LRU as the default backend (
etag.NewLRUCache) - defaults
to 4096 entries and a 256 MiB byte budget, both tunable via
etag.WithMaxBodyBytesandetag.WithMaxCacheBytes. - Pluggable
etag.Cacheinterface (Get/Add/Remove, all context-aware)
so you can drop in Redis, S3, bbolt, Pebble, or any other backend without
forking the kit. - Multi-tenant safety via
etag.WithKeyScope(...)- required whenever a cache
is shared across identities, so two installations hitting the same URL can
never read each other's bodies. - Automatic drift detection: every cacheable 200 is verified, and after 10
precompute mismatches inside a 60-second window the transport silently
falls back to passively echoing the server's ETag. After a one-hour
cooldown it probes a small fraction of requests; consecutive successes
restore precompute mode automatically. Wireetag.WithDriftDetected(...)
for an alert hook on each transition; call(*etag.Transport).Stats()for
/healthzor dashboard polling. - Explicit construction-time error if the supplied base transport isn't an
*http.Transport, instead of silently miscomputing every hash. Default
base disables gzip so the hash domain matches what GitHub signed. - Sanitised structured logging on a strict allowlist - no header values, no
hash prefixes, no auth lengths. Records are emitted via slog's*Context
variants (DebugContext,WarnContext,InfoContext,ErrorContext)
so a context-awareslog.Handlercan stamp request IDs onto every line.
The upstreamX-GitHub-Request-Idresponse header is included as
github_request_idfor cross-referencing with GitHub-side debugging.
Reactive rate limiting (ratelimit/)
- Thin facade over
gofri/go-github-ratelimit/v2
with sensible defaults for both GitHub primary and secondary limits. - Curated callbacks for the common observability hooks:
WithPrimaryLimitDetected,WithPrimaryLimitReset,
WithSecondaryLimitDetected,WithTotalSleepLimit,WithLogger. - Escape hatch via
ratelimit.WithUpstreamOptions(opts ...any)for any
upstream feature the kit hasn't curated yet.
Proactive client-side throttling (throttle/)
- Token-bucket throttle (built on
golang.org/x/time/rate) that caps RPS
before GitHub ever sees the request - useful for backfill and batch jobs
that would otherwise burst into secondary limits. - Standalone
throttle.NewTransport(base, rps, opts...)for hand-built
stacks, orghkit.WithRequestsPerSecond(rps, burst)from the top level.
Composable client construction (top-level ghkit package)
ghkit.New(...)- generic factory wrapper that returns whatever client
type your factory produces (*github.Client,githubX.NewClient, or any
func(*http.Client) T). ghkit itself has zero compile-time dependency
ongo-github, so you can pin any major version you like.ghkit.HTTPClient(...)- assemble just the transport stack and hand the
resulting*http.Clientto whichever SDK you prefer.- Options:
WithToken(pat)andWithTokenSource(src oauth2.TokenSource)for static
PATs and JIT auth respectively (works cleanly withghinstallationfor
local-key JWT signing orisometry/ghaitfor KMS-backed signing).WithETagCache(opts ...etag.Option)to plug in the ETag layer, with
the full sub-package option surface forwarded.WithRateLimit(opts ...ratelimit.Option)(default ON) and
WithRateLimitDisabled()for the rare cases you don't want it.WithRequestsPerSecond(rps, burst)for the proactive throttle.WithBaseTransport(rt),WithTimeout(d),WithUserAgent(ua),
WithLogger(l)for the usual transport-shape knobs.
- Layer order is load-bearing and documented: throttle, then rate-limit,
then oauth2, then etag, then the base transport. The kit only assembles
the layers you opt into. - GitHub Enterprise Server supported by passing a custom factory that calls
(*github.Client).WithEnterpriseURLs(...).
Documentation
README.mdwith quick-start, the full transport-stack diagram, recipes for
static PAT, GitHub App installation tokens (withghinstallationand
ghaitadapters), backfill jobs, GitHub Enterprise Server, and using
theetagsub-package on its own.MIGRATION.mdwith three before/after recipes - Kubernetes operator with
rotating PAT, multi-installation webhook processor, backfill/batch job,
plus a verification checklist and notes on behavioural differences worth
spotting before the swap.- Package-level
doc.go, runnableexample_test.go, and pkg.go.dev-rendered
reference for every exported symbol.
Tooling and CI
Makefiletargets:test,test-unit,test-live(live ETag drift
probe againstapi.github.com),test-fuzz(30s fuzz over the ETag
hash),bench,bench-update,lint(golangci-lint v2),
vuln(govulncheck),tidy.- GitHub Actions CI running lint, race-enabled tests, fuzzing, and the live
drift probe - so the day GitHub changes its ETag algorithm we know within
one CI run instead of when users start filing issues. - MIT license.
Dependencies
- Go 1.26.2
github.com/google/go-github/v85v85.0.0github.com/gofri/go-github-ratelimit/v2v2.0.2github.com/hashicorp/golang-lru/v2v2.0.7golang.org/x/oauth2v0.36.0golang.org/x/timev0.15.0