Documentation
¶
Overview ¶
Package ghkit bundles ETag caching, rate limiting, retry on transient failures, and a proactive token bucket behind a single options-pattern API. New, NewE and Adapt are generic over the returned client type, so ghkit has no compile-time dependency on any specific GitHub SDK, and no ghkit release ever forces an SDK version on you.
For github.com/google/go-github v87 and later, Adapt with NewE is the plainest option:
gh, err := ghkit.NewE(
ghkit.Adapt(github.NewClient, github.WithHTTPClient),
ghkit.WithToken(tok),
)
HTTPClient returns the *http.Client on its own if you would rather construct the SDK client yourself. That form fits every SDK, and it is what you want when the constructor needs its own options:
hc, err := ghkit.HTTPClient(ghkit.WithToken(tok)) gh, err := github.NewClient(github.WithHTTPClient(hc))
New takes a func(*http.Client) T factory, which fits github.com/shurcooL/githubv4's NewClient. NewE takes a func(*http.Client) (T, error) factory, for constructors that can fail.
Transport stack (outer -> inner, each layer optional):
http.Client
UserAgent (overwrites User-Agent) [WithUserAgent]
RateLimit (go-github-ratelimit v2) [default ON]
Throttle (x/time/rate proactive) [WithRequestsPerSecond]
Retry (5xx + transient net errors) [WithRetry]
oauth2.Transport (clones req, sets Auth) [WithToken/WithTokenSource]
ETag (hashes auth'd clone) [WithETagCache]
Base (*http.Transport,
DisableCompression=true) [WithBaseTransport]
RateLimit above Throttle: a secondary cooldown parks new arrivals at gofri's waitForRateLimit before they consume throttle tokens. Parked requests release through Throttle at cooldown end, bounded by burst. Pre-1.4 the order was inverted; parked requests stampeded at cooldown end.
Retry below both rate-limit layers: 429s are deferred to the reactive limiter. Above oauth2: retried requests get the latest token via oauth2's per-call Source.Token().
The ETag precompute algorithm is the reason to use this kit. GitHub's server-side ETag hash includes the Authorization header, so a passive store-and-forward cache falls over under rotating auth (GitHub App installation tokens refresh hourly). The etag sub-package reproduces that hash client-side so cached entries stay useful across rotations. Algorithm credit: https://github.com/bored-engineer/github-conditional-http-transport
Auth patterns ¶
ghkit offers two auth paths. Pick one; do not combine them.
ghkit owns auth: pass WithToken or WithTokenSource. ghkit inserts an oauth2.Transport into the stack and injects Authorization on every outbound request. Works for static PATs and for oauth2.TokenSource implementations (e.g. ghinstallation for GitHub App installation tokens).
ghkit is auth-free; the SDK owns auth via per-call cloning. Omit WithToken/WithTokenSource. Build one ghkit HTTPClient at startup, hand it to your SDK, and let the SDK inject the current token per call (e.g. go-github's WithAuthToken option, which sets auth above ghkit's shared transport). The ETag LRU and rate-limit bucket persist across token rotation. This is the canonical pattern for Kubernetes operators that reconcile with a per-reconcile installation token.
Sub-packages (etag, ratelimit, retry, throttle) are independently importable for callers composing their own stack. The pages sub-package adds a Go 1.23 range-over-func iterator over Link-header pagination; polling iterates an HTTP endpoint on a caller-tunable interval (workflow run / check run completion); search wraps `/search/*` envelope endpoints with cap and incomplete-results awareness; cond surfaces the change-vs-unchanged signal computed by the etag layer. All four run on any *http.Client, so the configured transport stack applies per attempt.
GraphQL / v4 compatibility ¶
HTTPClient returns an *http.Client usable with any GraphQL v4 library (e.g. github.com/shurcooL/githubv4). The etag layer no-ops on anything but GET, so v4 traffic flows through oauth2 + retry + ratelimit + throttle + UA without ETag caching. Use WithETagCache only when you also issue REST GETs through the same client.
Custom cache backends ¶
The etag.Cache interface (Get/Add/Remove) is the seam for Redis, DynamoDB, or any other store. Implement the three methods and pass the result via etag.WithCache; ghkit never holds a binary dependency on a backend.
Example (EtagOnly) ¶
Example_etagOnly uses only the etag sub-package inside a hand-built transport chain.
package main
import (
"fmt"
"net/http"
"github.com/pcanilho/go-github-kit/etag"
)
func main() {
rt, err := etag.NewTransport(nil,
etag.WithCache(etag.NewLRUCache(1024)),
etag.WithKeyScope("tenant-42"),
)
if err != nil {
fmt.Println("construct:", err)
return
}
hc := &http.Client{Transport: rt}
resp, err := hc.Get("https://api.github.com/meta")
if err != nil {
fmt.Println("get:", err)
return
}
if err := resp.Body.Close(); err != nil {
fmt.Println("close:", err)
}
}
Output:
Example (Paginated) ¶
Example_paginated walks a Link-paginated endpoint with the pages sub-package. The fixture serves three pages of two items each; the iterator yields one element at a time without the caller writing the Link-walking loop.
package main
import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"github.com/pcanilho/go-github-kit/ghtest"
"github.com/pcanilho/go-github-kit/pages"
)
func main() {
var srv *httptest.Server
srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
page := 1
if p := r.URL.Query().Get("page"); p == "2" {
page = 2
} else if p == "3" {
page = 3
}
base := srv.URL + r.URL.Path
if link := ghtest.LinkHeader(base, page, 2, 3); link != "" {
w.Header().Set("Link", link)
}
w.Header().Set("Content-Type", "application/json")
switch page {
case 1:
fmt.Fprint(w, `[{"id":1},{"id":2}]`)
case 2:
fmt.Fprint(w, `[{"id":3},{"id":4}]`)
case 3:
fmt.Fprint(w, `[{"id":5},{"id":6}]`)
}
}))
defer srv.Close()
type item struct {
ID int `json:"id"`
}
var ids []int
for it, err := range pages.As[item](context.Background(), srv.Client(), "GET", srv.URL+"/items", nil) {
if err != nil {
fmt.Println("err:", err)
return
}
ids = append(ids, it.ID)
}
fmt.Println(ids)
}
Output: [1 2 3 4 5 6]
Example (Throttle) ¶
Example_throttle wraps any http.RoundTripper in a token-bucket cap.
package main
import (
"fmt"
"net/http"
"net/http/httptest"
"github.com/pcanilho/go-github-kit/throttle"
)
func main() {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, "ok")
}))
defer srv.Close()
rt, err := throttle.NewTransport(http.DefaultTransport, 10.0, throttle.WithBurst(1))
if err != nil {
fmt.Println("construct:", err)
return
}
hc := &http.Client{Transport: rt}
resp, err := hc.Get(srv.URL)
if err != nil {
fmt.Println("get:", err)
return
}
defer resp.Body.Close()
fmt.Println(resp.StatusCode)
}
Output: 200
Index ¶
- Variables
- func Adapt[T, O any](factory func(...O) (T, error), httpOption func(*http.Client) O) func(*http.Client) (T, error)
- func HTTPClient(opts ...Option) (*http.Client, error)
- func New[T any](factory func(*http.Client) T, opts ...Option) (T, error)
- func NewE[T any](factory func(*http.Client) (T, error), opts ...Option) (T, error)
- type Option
- func WithBaseTransport(rt http.RoundTripper) Option
- func WithETagCache(opts ...etag.Option) Option
- func WithETagTransport(fn func(*etag.Transport)) Option
- func WithLogger(l *slog.Logger) Option
- func WithRateLimit(opts ...ratelimit.Option) Option
- func WithRateLimitDisabled() Option
- func WithRequestsPerSecond(rps float64, burst int) Option
- func WithRetry(opts ...retry.Option) Option
- func WithTimeout(d time.Duration) Option
- func WithToken(pat string) Option
- func WithTokenSource(src oauth2.TokenSource) Option
- func WithUserAgent(ua string) Option
Examples ¶
Constants ¶
This section is empty.
Variables ¶
var ( ErrConflictingAuth = errors.New("ghkit: WithToken and WithTokenSource are mutually exclusive") ErrConflictingRateLimit = errors.New("ghkit: WithRateLimit and WithRateLimitDisabled are mutually exclusive") ErrPreAuthedBaseWithAuth = errors.New("ghkit: WithBaseTransport with a non-*http.Transport base cannot be combined with WithToken or WithTokenSource") ErrNonPositiveRPS = errors.New("ghkit: WithRequestsPerSecond requires rps > 0 and burst >= 1") ErrNilFactory = errors.New("ghkit: New requires a non-nil factory function") ErrETagTransportType = errors.New("ghkit: WithETagTransport: constructed transport is not an *etag.Transport") )
Sentinel errors for config validation. Callers can use errors.Is to distinguish specific failure modes in tests or runtime handling.
Functions ¶
func Adapt ¶ added in v1.8.0
func Adapt[T, O any](factory func(...O) (T, error), httpOption func(*http.Client) O) func(*http.Client) (T, error)
Adapt converts an SDK constructor that is variadic over its own options into the func(*http.Client) (T, error) shape NewE takes. go-github v87 changed NewClient to `NewClient(opts ...ClientOptionsFunc) (*Client, error)`, which no longer binds to New or NewE on its own:
import "github.com/google/go-github/v90/github"
gh, err := ghkit.NewE(
ghkit.Adapt(github.NewClient, github.WithHTTPClient),
ghkit.WithToken(os.Getenv("GITHUB_TOKEN")),
)
Adapt pairs with NewE, not New: it returns an error-returning factory, so ghkit.New(ghkit.Adapt(...)) does not compile.
It fits constructors shaped func(...O) (T, error) only. SDKs taking a leading positional argument or a context, such as ghinstallation or google.golang.org/api, do not fit; build those on HTTPClient directly.
httpOption must be the SDK option constructor that accepts the *http.Client, which for go-github is the only such option. Options that would replace the transport stack (github.WithTransport) or read it back (github.WithEnvProxy) take other types and will not compile here.
Adapt passes no further options. When the constructor needs its own, pass a closure to NewE instead, as shown on NewE.
A nil factory or httpOption yields a nil result, so New and NewE report ErrNilFactory.
func HTTPClient ¶
HTTPClient builds an *http.Client with the configured transport stack. Returns an error when the option combination is invalid; see the sentinel errors above.
Example ¶
ExampleHTTPClient is the library-agnostic entry point: a configured *http.Client you can hand to any client library that takes one.
package main
import (
"fmt"
"io"
"net/http"
"net/http/httptest"
ghkit "github.com/pcanilho/go-github-kit"
)
func main() {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, "ok")
}))
defer srv.Close()
hc, err := ghkit.HTTPClient(
ghkit.WithToken("fake-token"),
ghkit.WithETagCache(),
)
if err != nil {
fmt.Println("construct:", err)
return
}
resp, err := hc.Get(srv.URL)
if err != nil {
fmt.Println("get:", err)
return
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
fmt.Print(string(body))
}
Output: ok
func New ¶
New builds an *http.Client via HTTPClient and plumbs it into the caller-supplied factory. Generic over the returned type so ghkit has no compile-time dependency on any specific GitHub SDK; pass whichever constructor you use at the call site.
Use New for constructors that cannot fail, which take the *http.Client as their only argument:
import "github.com/shurcooL/githubv4"
v4, err := ghkit.New(githubv4.NewClient,
ghkit.WithToken(os.Getenv("GITHUB_TOKEN")),
)
go-github v87 changed NewClient to `NewClient(opts ...ClientOptionsFunc) (*Client, error)`, so it no longer binds here. Use Adapt with NewE instead.
When factory is nil, New returns the zero value of T and ErrNilFactory. When HTTPClient returns an error (invalid option combination), New proxies the error.
func NewE ¶ added in v1.7.0
NewE is New for SDK constructors that return an error. For go-github, pair it with Adapt:
gh, err := ghkit.NewE(
ghkit.Adapt(github.NewClient, github.WithHTTPClient),
ghkit.WithToken(tok),
)
Pass a closure instead when the constructor needs its own options:
gh, err := ghkit.NewE(func(hc *http.Client) (*github.Client, error) {
return github.NewClient(
github.WithHTTPClient(hc),
github.WithEnterpriseURLs(baseURL, uploadURL),
)
}, ghkit.WithToken(tok))
Use New for constructors that cannot fail, such as githubv4.NewClient.
Factory errors are wrapped as "ghkit: factory: %w". A nil factory returns the zero value of T and ErrNilFactory.
Types ¶
type Option ¶
type Option interface {
// contains filtered or unexported methods
}
Option configures a Transport. The interface form (rather than a bare `func(*config)`) lets us evolve the API without breaking callers.
func WithBaseTransport ¶
func WithBaseTransport(rt http.RoundTripper) Option
WithBaseTransport supplies the bottom of the transport stack. When omitted, a cloned http.DefaultTransport with DisableCompression=true is used. Passing a non-nil RoundTripper that is not an *http.Transport is rejected when ETag caching is enabled (the gzip invariant cannot be enforced on an arbitrary wrapper). Passing nil is equivalent to omitting the option.
DO NOT combine WithBaseTransport with WithToken or WithTokenSource when the supplied transport is not a bare *http.Transport; two auth sources produce undefined winner.
func WithETagCache ¶
WithETagCache enables the precompute-mode ETag cache. Sub-options (etag.WithCache, etag.WithKeyScope, etc.) configure the cache backend and scope.
func WithETagTransport ¶ added in v1.7.0
WithETagTransport hands the constructed *etag.Transport to fn, so callers can poll Stats(). Without it the transport is unreachable: HTTPClient builds it internally and buries it under the layers above.
Enables the ETag layer on its own. fn runs once before HTTPClient returns. Repeated use accumulates rather than overwrites; a nil fn is ignored.
func WithLogger ¶
WithLogger supplies the slog.Logger used for diagnostic events.
The library is silent by default: omit this option (or pass nil) and no log records are emitted. When set, the supplied logger is forwarded to etag, ratelimit, and retry sub-packages as their default; per-sub-package WithLogger options inside WithRetry/WithETagCache/WithRateLimit can still override.
func WithRateLimit ¶
WithRateLimit configures the reactive rate limiter (go-github-ratelimit). The rate limiter is ENABLED by default; call this only to register callbacks or tune sleep limits.
func WithRateLimitDisabled ¶
func WithRateLimitDisabled() Option
WithRateLimitDisabled turns off the reactive rate limiter. Mutually exclusive with WithRateLimit; combining the two surfaces ErrConflictingRateLimit at construction.
func WithRequestsPerSecond ¶
WithRequestsPerSecond enables the proactive token-bucket throttle. rps <= 0 or burst < 1 returns an error at construction time.
func WithRetry ¶ added in v1.1.0
WithRetry enables the retry middleware. Sub-options (retry.WithMaxAttempts, retry.WithBackoff, retry.WithRetryOn, retry.WithLogger) configure the policy. The default predicate retries idempotent methods on 5xx and recognised transient network errors; 429 is hard-excluded so the rate limiter above owns it.
Retry sits between RateLimit and oauth2 in the chain: 429s never reach retry, and retried requests get the latest token via oauth2's per-call Source.Token().
Each retry attempt consumes a throttle token if WithRequestsPerSecond is in use. A worst-case failing request can briefly use maxAttempts times the nominal RPS budget.
func WithTimeout ¶
WithTimeout sets http.Client.Timeout on the returned client.
func WithToken ¶
WithToken configures static Personal Access Token authentication. Exactly one of WithToken or WithTokenSource may be set.
func WithTokenSource ¶
func WithTokenSource(src oauth2.TokenSource) Option
WithTokenSource configures auth via an oauth2.TokenSource. Use this for GitHub App installation tokens (via ghinstallation or similar) and any other rotating-token setup. Exactly one of WithToken or WithTokenSource may be set.
func WithUserAgent ¶
WithUserAgent sets the User-Agent header on every outbound request at the transport level. Applied after any SDK sets its own User-Agent, so the caller's value wins. User-Agent is not in GitHub's server-side ETag hash domain, so setting this does not interfere with the ETag cache.
An empty string is a no-op: the middleware is not inserted. To suppress User-Agent entirely, supply a base RoundTripper that sets an empty header.
Directories
¶
| Path | Synopsis |
|---|---|
|
Package cond surfaces the change-vs-unchanged signal that the etag transport already computes but currently erases before the response reaches the consumer.
|
Package cond surfaces the change-vs-unchanged signal that the etag transport already computes but currently erases before the response reaches the consumer. |
|
Package etag implements GitHub's reverse-engineered ETag algorithm and a conditional-request HTTP transport that uses it.
|
Package etag implements GitHub's reverse-engineered ETag algorithm and a conditional-request HTTP transport that uses it. |
|
Package ghtest provides minimal test helpers for code that uses ghkit.
|
Package ghtest provides minimal test helpers for code that uses ghkit. |
|
Package pages walks paginated GitHub REST responses by following the RFC 8288 Link header, exposing the iteration as a Go 1.23 range-over-func iterator.
|
Package pages walks paginated GitHub REST responses by following the RFC 8288 Link header, exposing the iteration as a Go 1.23 range-over-func iterator. |
|
Package polling iterates an HTTP endpoint on an interval, reusing the supplied *http.Client so the configured transport stack (RateLimit, Throttle, Retry, oauth2, ETag) applies per attempt.
|
Package polling iterates an HTTP endpoint on an interval, reusing the supplied *http.Client so the configured transport stack (RateLimit, Throttle, Retry, oauth2, ETag) applies per attempt. |
|
Package ratelimit is a thin facade over github.com/gofri/go-github-ratelimit/v2.
|
Package ratelimit is a thin facade over github.com/gofri/go-github-ratelimit/v2. |
|
Package retry wraps an http.RoundTripper with retries on transient failures (5xx responses, network errors, transport-level deadline exceeded).
|
Package retry wraps an http.RoundTripper with retries on transient failures (5xx responses, network errors, transport-level deadline exceeded). |
|
Package search iterates GitHub's /search/* envelope endpoints (`{total_count, incomplete_results, items[]}`).
|
Package search iterates GitHub's /search/* envelope endpoints (`{total_count, incomplete_results, items[]}`). |
|
Package throttle wraps an http.RoundTripper with a client-side token-bucket rate limiter backed by golang.org/x/time/rate.
|
Package throttle wraps an http.RoundTripper with a client-side token-bucket rate limiter backed by golang.org/x/time/rate. |