ghkit

package module
v1.6.1 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jun 4, 2026 License: MIT Imports: 11 Imported by: 0

README

ghkit

A small Go toolkit that wraps github.com/google/go-github (REST), github.com/shurcooL/githubv4 (GraphQL), or any func(*http.Client) T client factory with ETag caching, reactive rate limiting, and a client-side token bucket. Opt into what you need; compose the rest yourself.

CI Go Reference Go Report Card License

Why?

Most projects that talk to the GitHub API eventually reimplement the same three things: a conditional-request cache so repeated reads stop burning rate-limit quota, the well-known reactive rate limiter from go-github-ratelimit, and a client-side throttle for jobs that want a hard cap. This kit packages those behind one options-pattern constructor so you can pick them up together, or import the sub-packages a la carte if you already have one and just want the others.

The headline feature is the ETag layer. GitHub's server-side ETag hash includes the Authorization header, which means a passive store-and-forward cache falls apart the moment your token rotates. That happens on a fixed 60-minute cadence under GitHub App installation tokens, and on whatever schedule you set for fine-grained PATs. The kit reproduces GitHub's hash client-side so cached entries keep working across rotations and your 304 hit rate stays high.

Install or Update

go get -u github.com/pcanilho/go-github-kit

Quick start

package main

import (
    "context"
    "fmt"
    "log"
    "os"

    "github.com/google/go-github/v85/github"
    ghkit "github.com/pcanilho/go-github-kit"
)

func main() {
    gh, err := ghkit.New(github.NewClient,
        ghkit.WithToken(os.Getenv("GITHUB_TOKEN")),
        ghkit.WithETagCache(),
    )
    if err != nil {
        log.Fatal(err)
    }
    repo, _, err := gh.Repositories.Get(context.Background(), "google", "go-github")
    if err != nil {
        log.Fatal(err)
    }
    fmt.Println(repo.GetFullName())
}

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.

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

How?

http.Client
 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]

Each layer is optional. The stack is opt-in: ghkit.HTTPClient(...) only includes the layers you asked for. The order is load-bearing. ETag sits below oauth2 so it hashes the request with the current Authorization header. RateLimit sits above Throttle so 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. Retry sits below both rate-limit layers so 429s are deferred to the reactive limiter; sits above oauth2 so retried requests get the latest token via oauth2's per-call Source.Token().

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.

Recipes

Recommended setup for a long-lived service
gh, err := ghkit.New(github.NewClient,
    ghkit.WithToken(os.Getenv("GITHUB_TOKEN")),
    ghkit.WithETagCache(),
    ghkit.WithRetry(),
    ghkit.WithUserAgent("my-app/1.0"),
    ghkit.WithTimeout(30*time.Second),
)

Defaults are tuned for steady-state operators: rate-limit on, retry 3 attempts with decorrelated jitter on idempotent 5xx + transient net errors, etag at 4096 entries / 256 MiB. Tune downward via the per-layer options if your workload differs. There is no RecommendedDefaults() API on purpose: the constructor defaults are the single source of truth.

Static PAT with ETag caching
gh, err := ghkit.New(github.NewClient,
    ghkit.WithToken(os.Getenv("GITHUB_TOKEN")),
    ghkit.WithETagCache(),
)

The default cache is a 4096-entry in-process LRU with a 256 MiB byte budget; safe to run in a long-lived process without watching it grow.

GraphQL with shurcooL/githubv4
import (
    "github.com/shurcooL/githubv4"
    ghkit "github.com/pcanilho/go-github-kit"
)

v4, err := ghkit.New(githubv4.NewClient,
    ghkit.WithToken(os.Getenv("GITHUB_TOKEN")),
    ghkit.WithRetry(),
)

ghkit.New is generic; githubv4.NewClient satisfies func(*http.Client) *githubv4.Client and gets oauth2 + retry + ratelimit + throttle + UA from the transport stack. ETag caching is REST-only by design (the etag layer no-ops on POST), so WithETagCache is a no-op for v4 traffic; leave it off unless you also issue REST GETs through the same client.

A runnable version lives at examples/graphql-v4/.

Iterating over paginated results

GitHub REST endpoints paginate via RFC 8288 Link headers. The pages sub-package walks them with a Go 1.23 range-over-func iterator, reusing the configured *http.Client so RateLimit, Throttle, Retry, oauth2, and ETag all apply per page automatically.

import (
    "context"
    "fmt"
    "net/http"
    "os"

    "github.com/google/go-github/v85/github"
    ghkit "github.com/pcanilho/go-github-kit"
    "github.com/pcanilho/go-github-kit/pages"
)

hc, err := ghkit.HTTPClient(
    ghkit.WithToken(os.Getenv("GITHUB_TOKEN")),
    ghkit.WithETagCache(),
)
if err != nil { panic(err) }

headers := http.Header{
    "Accept":               []string{"application/vnd.github+json"},
    "X-GitHub-Api-Version": []string{"2022-11-28"},
}

var n int
for repo, err := range pages.As[*github.Repository](
    context.Background(), hc, "GET",
    "https://api.github.com/user/repos?per_page=100", headers,
) {
    if err != nil { panic(err) }
    fmt.Println(repo.GetFullName())
    n++
}
fmt.Printf("total: %d\n", n)

pages.As[T] decodes each page into []T and yields one element at a time; the iterator owns the response body. pages.Pages is the lower-level form that yields *http.Response per page when the caller wants to handle decoding directly.

For tests, ghtest.LinkHeader(baseURL, page, perPage, lastPage) builds RFC 8288 Link header values. See examples/list-all-repos/ for a runnable end-to-end demo.

Polling a long-running operation (workflow run, check run, deployment)

The polling sub-package iterates an HTTP endpoint on a caller-tunable interval, reusing the configured *http.Client so retry, etag, ratelimit, throttle, and oauth2 apply per attempt. polling.As[T] decodes each response into T; WithDoneT stops on the decoded value; WithMaxWallClock caps total time and wraps context.DeadlineExceeded.

import (
    "context"
    "errors"
    "log"
    "net/http"
    "os"
    "time"

    "github.com/google/go-github/v85/github"
    ghkit "github.com/pcanilho/go-github-kit"
    "github.com/pcanilho/go-github-kit/polling"
    "github.com/pcanilho/go-github-kit/retry"
)

hc, err := ghkit.HTTPClient(
    ghkit.WithToken(os.Getenv("GITHUB_TOKEN")),
    ghkit.WithRetry(retry.WithMaxAttempts(1)), // polling owns the loop
    ghkit.WithETagCache(),
)
if err != nil { panic(err) }

ctx, cancel := context.WithTimeout(context.Background(), 35*time.Minute)
defer cancel()

url := "https://api.github.com/repos/owner/repo/actions/runs/12345"
seq := polling.As[*github.WorkflowRun](
    ctx, hc, http.MethodGet, url,
    http.Header{"Accept": []string{"application/vnd.github+json"}},
    nil, 15*time.Second,
    polling.WithDoneT(func(r *github.WorkflowRun) bool { return r.GetStatus() == "completed" }),
    polling.WithMaxWallClock(30*time.Minute),
    polling.WithJitter(0.2),
)

var run *github.WorkflowRun
for r, err := range seq {
    if err != nil {
        if errors.Is(err, polling.ErrMaxWallClockExceeded) { log.Fatal("budget exceeded") }
        log.Fatal(err)
    }
    run = r
}
log.Printf("conclusion=%s", run.GetConclusion())

Sharp edges: each c.Do may itself loop through retry.Transport (pass retry.WithMaxAttempts(1) when polling owns the outer loop); throttle.WithRequestsPerSecond below 1/interval dominates cadence; with WithETagCache an unchanged resource yields identical decoded bytes per tick (pair with polling.WithChangeOnly to skip those silently). Pages-shape body ownership: Poll yields *http.Response and the caller closes; As[T] owns and closes via defer.

See examples/poll-workflow-run/ for a runnable demo.

Searching with envelope pagination (1000-cap aware)

GitHub's /search/* endpoints return an envelope ({total_count, incomplete_results, items[]}), so pages.As[T] cannot serve them directly. The search sub-package wraps the four common endpoints (Issues, Code, Repos, Users) and surfaces both IncompleteResults (timed-out queries) and the post-page-10 1000-result hard cap as ErrResultCapHit.

import (
    "context"
    "errors"
    "fmt"

    "github.com/google/go-github/v85/github"
    "github.com/pcanilho/go-github-kit/search"
)

for r, err := range search.Issues[*github.Issue](
    context.Background(), hc, "is:open is:pr author:torvalds",
    search.WithPerPage(100),
    search.WithSort("updated"),
    search.WithOrder("desc"),
) {
    if err != nil {
        if errors.Is(err, search.ErrResultCapHit) {
            // Refine the query with a `created:<...` date filter.
            break
        }
        panic(err)
    }
    if r.IncompleteResults {
        // GitHub timed out server-side on this page; results may be partial.
    }
    fmt.Printf("[%d] %s\n", r.TotalCount, r.Item.GetHTMLURL())
}

Implementation reuses pages.Pages for Link-header walking, so per-page retry/etag/ratelimit/throttle composition is automatic. Search has its own X-RateLimit-Resource budget; gofri routes requests transparently. See examples/search-issues/.

Detecting unchanged resources (visible 304)

The etag layer transparently converts 304 responses into synthesised 200s with the cached body. The cond sub-package surfaces the change-vs-unchanged signal that is otherwise erased, letting consumers skip JSON parse, structural diff, DB writes, and webhook fan-out when nothing changed.

import (
    "context"
    "encoding/json"
    "io"
    "net/http"

    "github.com/google/go-github/v85/github"
    "github.com/pcanilho/go-github-kit/cond"
)

req, _ := http.NewRequest(http.MethodGet, "https://api.github.com/repos/google/go-github", nil)
req.Header.Set("Accept", "application/vnd.github+json")

repo, status, err := cond.Fetch(context.Background(), hc, req,
    func(r io.Reader) (*github.Repository, error) {
        var v github.Repository
        return &v, json.NewDecoder(r).Decode(&v)
    },
)
if err != nil { panic(err) }
switch status {
case cond.Updated:    // wire 200 (or no etag layer in chain)
case cond.Unchanged:  // synth 200 from cache hit; resource hasn't changed
}

Mechanics: the etag layer sets cond.HeaderCacheStatus ("X-Ghkit-Cache") on synth-200 ("hit") and wire-200-store ("miss") paths. cond.StatusOf(resp) reads the header. Absent header → Updated (no etag layer in chain → caller behaves as if every response is fresh). Pair with polling.WithChangeOnly to silently skip yields when polling an unchanged resource. See examples/conditional-fetch/.

GitHub App installation tokens (JIT auth, shared cache)
import (
    ghkit "github.com/pcanilho/go-github-kit"
    "github.com/pcanilho/go-github-kit/etag"
)

// In production, build this with ghinstallation (plain local-key JWT
// signing) or ghait (KMS-backed signing via AWS/GCP/Azure/Vault, selected
// via build tags so you only pull in the SDK you actually use). Both vend
// a fresh installation token on each Token() call; the transport picks up
// the new value per request.
var source oauth2.TokenSource // = ghinstallation.New(...) or wrap ghait.NewToken

// One cache shared across all installations in this process.
cache := etag.NewLRUCache(8192)

hc, err := ghkit.HTTPClient(
    ghkit.WithTokenSource(source),
    ghkit.WithETagCache(
        etag.WithCache(cache),
        etag.WithKeyScope(fmt.Sprintf("installation-%d", installationID)),
    ),
    ghkit.WithTimeout(5 * time.Second),
)
if err != nil { return err }
gh := github.NewClient(hc)

WithKeyScope is required whenever you supply a Cache yourself. It namespaces entries so two installations hitting the same URL never read each other's bodies.

Signer options for the oauth2.TokenSource:

  • bradleyfalzon/ghinstallation for local-key JWT signing (the common default).
  • isometry/ghait for KMS-backed signing (AWS, GCP, Azure, Vault, or a local file). Each KMS provider is behind a build tag so you only pull the SDK you use.
ghait adapter

ghait.NewGHAIT returns a factory whose NewToken(ctx) mints an *InstallationToken. Adapt it to oauth2.TokenSource and wrap with oauth2.ReuseTokenSource so you mint one token per hour, not per request:

type ghaitSource struct {
    ctx     context.Context
    factory ghait.TokenFactory
}

func (s *ghaitSource) Token() (*oauth2.Token, error) {
    t, err := s.factory.NewToken(s.ctx)
    if err != nil {
        return nil, err
    }
    return &oauth2.Token{AccessToken: t.GetToken(), Expiry: t.GetExpiresAt().Time}, nil
}

// Build with -tags=aws|gcp|azure|vault|file to pull only the SDK you use.
factory, _ := ghait.NewGHAIT(ctx, ghait.NewConfig(appID, installationID, "aws", keyRef))
source := oauth2.ReuseTokenSource(nil, &ghaitSource{ctx: ctx, factory: factory})

Pass source to ghkit.WithTokenSource as in the recipe above.

Multi-tenant single client (one Transport, many installations)
import (
    "github.com/pcanilho/go-github-kit/etag"
)

type tenantKey struct{}

cache := etag.NewLRUCache(8192)

hc, err := ghkit.HTTPClient(
    ghkit.WithTokenSource(perTenantTokenSource), // resolves token per req.Context()
    ghkit.WithETagCache(
        etag.WithCache(cache),
        etag.WithAutoKeyScope(func(req *http.Request) (string, error) {
            id, ok := req.Context().Value(tenantKey{}).(string)
            if !ok || id == "" {
                return "", fmt.Errorf("tenant id missing from request context")
            }
            return id, nil
        }),
    ),
)

Use WithAutoKeyScope instead of WithKeyScope when one *http.Client serves N tenants (typical for warm Lambda pools fronting many GitHub App installations). The fn is invoked per request and must return either a non-empty scope or a non-nil error; an empty string with a nil error surfaces as etag.ErrEmptyScope. WithKeyScope and WithAutoKeyScope are mutually exclusive.

Backfill shape with a proactive RPS cap
gh, err := ghkit.New(github.NewClient,
    ghkit.WithToken(os.Getenv("GITHUB_TOKEN")),
    ghkit.WithETagCache(etag.WithCache(etag.NewLRUCache(8192))),
    ghkit.WithRequestsPerSecond(1.3, 1),
)

WithRequestsPerSecond is a standard x/time/rate token bucket. It adds a client-side cap on top of the reactive limiter, which is useful for batch jobs that want predictable pacing under sustained load.

GitHub Enterprise Server
gh, err := ghkit.New(func(hc *http.Client) *github.Client {
    c, ghErr := github.NewClient(hc).WithEnterpriseURLs(
        "https://github.example.com/api/v3/",
        "https://github.example.com/api/uploads/",
    )
    if ghErr != nil {
        return github.NewClient(hc) // fall back to github.com on a bad URL
    }
    c.UserAgent = "my-app/1.0"
    return c
}, ghkit.WithToken(os.Getenv("GITHUB_ENTERPRISE_TOKEN")))

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().

Retry on transient failures (5xx, network errors)
gh, err := ghkit.New(github.NewClient,
    ghkit.WithToken(os.Getenv("GITHUB_TOKEN")),
    ghkit.WithRetry(), // 3 attempts, 200ms..2s decorrelated jitter, idempotent methods only
)

Tuned policy with POST opt-in via Idempotency-Key:

import "github.com/pcanilho/go-github-kit/retry"

gh, err := ghkit.New(github.NewClient,
    ghkit.WithToken(token),
    ghkit.WithRetry(
        retry.WithMaxAttempts(5),
        retry.WithBackoff(500*time.Millisecond, 10*time.Second),
        retry.WithRetryOn(func(req *http.Request, resp *http.Response, err error) bool {
            // Retry POST/PATCH when the caller asserted idempotency.
            if req.Header.Get("Idempotency-Key") != "" {
                if err != nil { return retry.IsTransientNetErr(err) }
                return resp != nil && retry.IsRetryable5xx(resp.StatusCode)
            }
            // Otherwise the default behaviour.
            if !retry.IsIdempotent(req.Method) { return false }
            if err != nil { return retry.IsTransientNetErr(err) }
            return resp != nil && retry.IsRetryable5xx(resp.StatusCode)
        }),
    ),
)

POST/PATCH retries with a body require req.GetBody; http.NewRequest only sets it for *bytes.Buffer, *bytes.Reader, and *strings.Reader. For other readers, set it manually so the retry layer can rewind on attempt 2+.

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 (nil, retry.ErrRetryAfterExceedsMax) and the transport has already drained and closed the prior response.

Wiring metrics to etag and ratelimit callbacks

ghkit imports no metrics library; counters attach to etag.WithEventCallback and the named ratelimit callbacks.

import (
    "context"
    "fmt"
    "net/http"
    "time"

    "github.com/prometheus/client_golang/prometheus"
    "github.com/pcanilho/go-github-kit/etag"
    "github.com/pcanilho/go-github-kit/ratelimit"
)

var (
    cacheEvents = prometheus.NewCounterVec(prometheus.CounterOpts{Name: "ghkit_etag_events_total"},     []string{"kind"})
    reqs        = prometheus.NewCounterVec(prometheus.CounterOpts{Name: "ghkit_requests_total"},        []string{"status_class", "from_cache"})
    mismatches  = prometheus.NewCounterVec(prometheus.CounterOpts{Name: "ghkit_etag_mismatches_total"}, []string{"path_template"})
    rl          = prometheus.NewCounterVec(prometheus.CounterOpts{Name: "ghkit_ratelimit_total"},       []string{"kind", "category"})
)

func transport(scope string) (http.RoundTripper, error) {
    etagRT, err := etag.NewTransport(nil,
        etag.WithKeyScope(scope),
        etag.WithEventCallback(func(_ context.Context, ev etag.Event) {
            cacheEvents.WithLabelValues(string(ev.Kind)).Inc()
            if ev.Status > 0 {
                fromCache := ev.Kind == etag.KindHit || ev.Kind == etag.KindValidatedOK
                reqs.WithLabelValues(fmt.Sprintf("%dxx", ev.Status/100), fmt.Sprint(fromCache)).Inc()
            }
            if ev.Kind == etag.KindMismatch {
                mismatches.WithLabelValues(ev.PathTemplate).Inc()
            }
        }),
    )
    if err != nil {
        return nil, err
    }
    return ratelimit.NewTransport(etagRT,
        ratelimit.WithTotalSleepLimit(time.Hour),
        ratelimit.WithPrimaryLimitDetected(func(ev *ratelimit.PrimaryEvent) {
            rl.WithLabelValues("primary", string(ev.Category)).Inc()
        }),
        ratelimit.WithSecondaryLimitDetected(func(*ratelimit.SecondaryEvent) {
            rl.WithLabelValues("secondary", "").Inc()
        }),
    ), nil
}

Metric names are illustrative; substitute your registry conventions.

Use only the etag sub-package in a hand-built stack
import "github.com/pcanilho/go-github-kit/etag"

rt, err := etag.NewTransport(nil, // nil = default base with DisableCompression=true
    etag.WithCache(etag.NewLRUCache(1024)),
    etag.WithKeyScope("tenant-42"),
)
if err != nil { return err }
hc := &http.Client{Transport: rt}
gh := github.NewClient(hc)

Testing your code

The ghtest sub-package provides two helpers for the GitHub-specific traps in writing tests: secondary-rate-limit classification and the bored-engineer ETag hash domain. See TESTING.md for the full recipe set.

Migrating from an in-tree GitHub transport

If your repo already has a hand-rolled oauth2.Transport + go-github-ratelimit + custom ETag transport stack, MIGRATION.md maps the most common shapes (Kubernetes operator, multi-installation webhook processor, backfill job) to ghkit's options API with concrete before/after snippets and notes on behavioral differences worth checking before the swap.

How the ETag layer works

GitHub's server-side ETag hash includes the Authorization header. Store the server's ETag and send it back on the next request, and you get near-zero hit rate the moment the token rotates: the server-side hash has moved, the cached ETag no longer matches, and every request goes through as a full 200. That's the default state for anyone running GitHub App installation tokens.

The precompute trick, reverse-engineered by bored-engineer, is to reproduce that hash client-side at request time using the current Authorization header. The cached body stays valid across rotations; If-None-Match is recomputed on the fly. Hit rate stays high, quota savings become durable, and GitHub Apps actually benefit from caching instead of fighting it.

The algorithm walkthrough lives at https://www.bored-engineer.com/posts/github-etag-algorithm/.

What happens when GitHub changes the algorithm. Every cacheable 200 is validated: the transport recomputes the expected ETag and compares it to the server's. After 10 mismatches inside a 60-second window, the transport silently switches to sending the server's stored ETag as If-None-Match -- 304s resume on stable bodies, you pay at most one extra miss per URL when the algorithm changes. After a 1-hour cooldown, the transport probes back to precompute on a small fraction of requests; consecutive successes restore precompute mode automatically, so a transient drift blip doesn't permanently degrade a long-running process. Wire etag.WithEventCallback(...) and filter on etag.KindDriftDetected / etag.KindDriftRecovered for transition alerts; call (*etag.Transport).Stats() for /healthz or dashboard polling. Stats exposes per-Outcome counters (TotalHits/TotalMisses/TotalStores/TotalBypasses) for hit-rate metrics without paying for DEBUG-level slog ingestion. For per-call attribution (URL, repo, consumer-side context like webhook event type), the same WithEventCallback hook delivers every cache decision. The fallback itself is unconditional and has no public knob -- this is by design.

What this kit adds on top of the original idea:

  • A bounded in-process Cache (LRU) as the default backend.
  • Multi-tenant safety via etag.WithKeyScope(...) so one cache can be shared across installations without cross-tenant leaks.
  • A live drift probe against api.github.com in CI, so the day GitHub changes the algorithm, we know within one CI run.
  • Sanitised structured logging with a strict field allowlist (no header values, no hash prefixes, no auth lengths).

BYO storage

etag.Cache is a three-method interface that takes a context on every call so network-backed backends (Redis, S3, etc.) can honour deadlines and cancellation:

type Cache interface {
    Get(ctx context.Context, key string) (Entry, bool, error)
    Add(ctx context.Context, key string, e Entry) error
    Remove(ctx context.Context, key string) error
}

The kit ships etag.NewLRUCache(size) as the only built-in (in-process, memory-bounded, ignores the context because there's no network I/O to cancel). Swap it for Redis, bbolt, S3, or anything else by implementing the interface. bored-engineer's repo has backend examples (memory, bbolt, pebble, redis, s3) you can adapt. This kit deliberately doesn't ship those itself so you don't pay for five dependency trees on day one. Open an issue when a backend shape becomes common enough to standardise.

Things worth knowing

Gzip has to be disabled on the underlying transport, otherwise the hash domain diverges from what GitHub signed. The default base is a clone of http.DefaultTransport with DisableCompression=true; if you pass your own base via WithBaseTransport and it isn't an *http.Transport, construction fails with an explicit error rather than silently miscomputing every hash.

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.

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).

Using a different go-github version

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:

Generic factory (when you want type inference to pick up *github.Client):

import githubX "github.com/google/go-github/vX/github"

gh, err := ghkit.New(githubX.NewClient,
    ghkit.WithToken(os.Getenv("GITHUB_TOKEN")),
    ghkit.WithETagCache(),
)

Library-agnostic (when you want the *http.Client and will wire your own client library):

import githubX "github.com/google/go-github/vX/github"

hc, err := ghkit.HTTPClient(
    ghkit.WithToken(os.Getenv("GITHUB_TOKEN")),
    ghkit.WithETagCache(),
)
gh := githubX.NewClient(hc)

The runnable demos under examples/ live in their own sub-module and pin a specific go-github version (currently v85) so the kit's main go.mod stays clean across go-github upgrades.

Development

make test       # go test -race ./...
make test-unit  # short tests only
make test-live  # the live ETag drift probe (needs GITHUB_TOKEN)
make test-fuzz  # fuzz the ETag hash for 30s
make lint       # golangci-lint v2
make vuln       # govulncheck on the module
make bench      # write benchmarks to dist/bench-current.txt

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 is generic over the returned client type, so ghkit has no compile-time dependency on any specific GitHub SDK; pass any func(*http.Client) T factory at the call site: github.com/google/go-github's NewClient for REST, github.com/shurcooL/githubv4's NewClient for GraphQL, or any other client constructor that takes an *http.Client.

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.

  1. 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).

  2. 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 (*Client).WithAuthToken, which clones the go-github Client 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 POST, 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)
	}
}
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

Examples

Constants

This section is empty.

Variables

View Source
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")
)

Sentinel errors for config validation. Callers can use errors.Is to distinguish specific failure modes in tests or runtime handling.

Functions

func HTTPClient

func HTTPClient(opts ...Option) (*http.Client, error)

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

func New[T any](factory func(*http.Client) T, opts ...Option) (T, error)

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.

Canonical usage:

import "github.com/google/go-github/v85/github"

gh, err := ghkit.New(github.NewClient,
    ghkit.WithToken(os.Getenv("GITHUB_TOKEN")),
    ghkit.WithETagCache(),
)

Custom construction (UserAgent, GitHub Enterprise BaseURL, any other post-construction tweaks) goes inside a factory closure:

gh, err := ghkit.New(func(hc *http.Client) *github.Client {
    c := github.NewClient(hc)
    c.UserAgent = "my-app/1.0"
    return c
}, opts...)

For GitHub Enterprise, call github.NewClient(hc).WithEnterpriseURLs(base, upload) inside the closure. The base URL must end with a trailing slash; go-github returns an error if it does not.

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.

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

func WithETagCache(opts ...etag.Option) Option

WithETagCache enables the precompute-mode ETag cache. Sub-options (etag.WithCache, etag.WithKeyScope, etc.) configure the cache backend and scope.

func WithLogger

func WithLogger(l *slog.Logger) Option

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

func WithRateLimit(opts ...ratelimit.Option) Option

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

func WithRequestsPerSecond(rps float64, burst int) Option

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

func WithRetry(opts ...retry.Option) Option

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

func WithTimeout(d time.Duration) Option

WithTimeout sets http.Client.Timeout on the returned client.

func WithToken

func WithToken(pat string) Option

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

func WithUserAgent(ua string) Option

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.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL