All notable changes to go-github-kit are documented in this file.
The format is based on Keep a Changelog 1.1.0, and this project adheres to Semantic Versioning.
Maintenance release. Bumps the Go toolchain to 1.26.4 and refreshes CI tooling and pinned action versions. No library source or public API changes since 1.6.0.
- Go toolchain bumped to 1.26.4 (
go.mod). - CI
govulncheckstep refactored. - Pinned GitHub Actions bumped:
actions/checkout,golangci/golangci-lint-action,codecov/codecov-action.
- Added a
WithEventCallbackusage example.
1.6.0 - 2026-05-07
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.
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, bothno_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 theetag_prefix that the slog kind attributes carry, for naming consistency with the bareetag_eventkinds.
etag.WithDriftDetected(cb func(DriftEvent)) Option. Useetag.WithEventCallback(cb)and filter onevt.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 arecover()guard around the drift callback; v1.6 does not catch panics fromWithEventCallback, so the corresponding slog event no longer fires. Panics propagate up throughRoundTrip.
MIGRATION.mdgains a "v1.6: per-call event attribution" recipe showingWithEventCallbackctx propagation, repo extraction fromEvent.URL, and the per-page callback fan-out under thepagespackage.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.
1.5.0 - 2026-05-02
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.
retry:Retry-Afterparser saturates extremely large delta-seconds values (abovemath.MaxInt64 / int64(time.Second)) instead of wrapping int64 nanoseconds. Clamped values route through the existingErrRetryAfterExceedsMaxabort path.retry: whitespace aroundRetry-Aftervalues (" 5","5 ") is now trimmed before parsing.
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. SentinelsErrMaxAttemptsExceeded,ErrMaxWallClockExceeded(wrapscontext.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 asErrResultCapHit. 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 andComputeExpectedETagare 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-sanitisedrawattribute (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.
- 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 thesource="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).
- 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/issueswithsearch.Issues[*github.Issue], surfacesincomplete_resultsand the 1000-result cap. - New
examples/conditional-fetch/: visible 304 viacond.Fetch[*github.Repository]; the second call returnscond.Unchangedso downstream work can be skipped.
polling,search, andcondintroduce no new runtime dependencies in the root module. Theghtest.ETagServerlift adds no module surface;condis stdlib-only;pollingimportscondandretry;searchimportspages. The import graph remains acyclic (etag -> cond,polling -> cond, retry,search -> pages).
1.4.0 - 2026-05-02
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.
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 missingrel="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 "" whenlastPage <= 1so handlers can branch on a single string. Pairs withghtest.Write304IfMatchandghtest.WriteSecondaryLimit.
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.AsplusWithETagCache.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.
- New
examples/list-all-repos/: usespages.As[*github.Repository]to walk/user/reposagainstapi.github.comend to end. Shows the iterator composing withWithETagCacheandWithUserAgent.
No changes in the root module. The examples/ module is unchanged.
.golangci.yaml:bodycloseadded to the test-file linter exclusion list. The iter.Seq2 yield-then-close pattern inpages_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:bodyclosedirectives inpages/pages.goremain (caller-closes contract on yielded responses; close-via-decodePage on the typed wrapper).
1.3.1 - 2026-05-01
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.
- 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.
1.3.0 - 2026-04-30
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.
etag.WithAutoKeyScope(fn func(*http.Request) (string, error)): derives the cache-key scope per request. Mutually exclusive withetag.WithKeyScope; combining both yieldsetag.ErrConflictingScope. Either option satisfies the caller-supplied-Cachescope requirement.etag.ErrEmptyScope: sentinel wrapped and returned fromRoundTripwhen 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 bothWithKeyScopeandWithAutoKeyScopeare set.
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-method
etag.Cacheinterface.
- UA without ETag caching) and custom cache backends via the
three-method
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/installation-token/main.goreplaces theoauth2.StaticTokenSourcestand-in with an inlinebradleyfalzon/ghinstallation/v2tooauth2.TokenSourceadapter (the canonical local-key JWT signing path).- New
examples/graphql-v4/runs a minimalViewer.Loginquery throughshurcooL/githubv4over a ghkit-built*http.Client. Pins compile-time compatibility with the v4 SDK in the examples CI lane.
No changes in the root module. The examples/ module gains
bradleyfalzon/ghinstallation/v2, golang-jwt/jwt/v4 (transitive),
shurcooL/githubv4, and shurcooL/graphql (transitive).
1.2.1 - 2026-04-28
Three bug fixes in transport behaviour and a small set of godoc clarifications. No API surface changes, no new dependencies.
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 standardghkit.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.
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 require
req.GetBody.
1.2.0 - 2026-04-26
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.
ghtest.WriteSecondaryLimit(w, retryAfter)writes a 403 withRetry-After(whole seconds; negative durations clamp to zero) and a JSON body whosedocumentation_urlends in#secondary-rate-limits. That suffix is whatgofri/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 inIf-None-Match(split on commas, trim whitespace, stripW/weak prefix and surrounding quotes), and on any match sets a quoted RFC 7232ETagresponse 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.
- 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-mockfor the SDK-layer mocking case. - README "Testing your code" section, three lines linking to
TESTING.md.
No changes. Same as 1.1.0.
1.1.0 - 2026-04-26
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.
- 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() != nilalways stops retries before any predicate is consulted, and during backoff sleep viatime.NewTimer+Stop(no leaked timers on longRetry-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.
- 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.
- 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.
- Silent by default.
ghkit,etag,ratelimit, andretryno longer default-initialise aslog.Default()logger. Without an explicitWithLogger(...)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-levelWithLoggerinstead 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)thenWithLogger(nil)to silence on a per-construction basis.retry.IsTransientNetErrnow short-circuits known-permanent failures tofalse: 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 existingErrConflictingAuthprecedent forWithToken+WithTokenSource.
- New runnable example at
examples/retry-on-flaky/demonstratingWithRetrywith a tuned backoff and a custom predicate that opts POST in viaIdempotency-Key.
No changes. Same as 1.0.0.
1.0.0 - 2026-04-25
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.
- 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 viaetag.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. Wire
etag.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
*Contextvariants (DebugContext,WarnContext,InfoContext,ErrorContext) so a context-awareslog.Handlercan stamp request IDs onto every line. The upstreamX-GitHub-Request-Idresponse header is included asgithub_request_idfor cross-referencing with GitHub-side debugging.
- Thin facade over
gofri/go-github-ratelimit/v2with 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.
- 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.
ghkit.New(...)- generic factory wrapper that returns whatever client type your factory produces (*github.Client,githubX.NewClient, or anyfunc(*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) andWithRateLimitDisabled()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(...).
README.mdwith quick-start, the full transport-stack diagram, recipes for static PAT, GitHub App installation tokens (withghinstallationandghaitadapters), 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.
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.
- 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