githubkit

package module
v0.2.0 Latest Latest
Warning

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

Go to latest
Published: Aug 16, 2026 License: MIT Imports: 18 Imported by: 0

README

go-github-kit

A thin operational kernel over google/go-github/v69: authentication, rate-limit awareness, retry with backoff, sentinel errors, ETag conditional caching, and concurrent pagination — while you keep using the native SDK types everywhere.

CI Go Reference

Why

Every project that talks to the GitHub API hand-rolls the same five concerns, usually incompletely: a token resolved from some environment variable, rate limits discovered the hard way (a 403 at 2 a.m.), retry loops that replay non-idempotent POSTs, pagination loops that fetch serially, and error handling that string-matches status codes. One real production incident started exactly this way: a service authenticating with a bare PAT and no rate limiting at all.

This kit centralizes that plumbing once, as an HTTP transport stack under the native SDK. It is deliberately not a client wrapper: Kernel embeds *github.Client, so every native SDK method works directly, and code written against *github.Client compiles unchanged.

Quick start

package main

import (
	"context"
	"fmt"

	"github.com/LarsArtmann/go-github-kit"
	gh "github.com/google/go-github/v69/github"
)

func main() {
	// Resolves GITHUB_TOKEN or GH_TOKEN; any other env names work too:
	// githubkit.WithAuthTokenFromEnv("MY_TOKEN"). Explicit tokens:
	// githubkit.WithPAT("ghp_...").
	kernel, err := githubkit.New(githubkit.WithAuthTokenFromEnv())
	if err != nil {
		panic(err) // githubkit.ErrAuthRequired when no token is found
	}

	ctx := context.Background()

	// Native SDK, unchanged — the kernel stack is underneath.
	user, _, err := kernel.Users.Get(ctx, "")
	if err != nil {
		panic(err)
	}
	fmt.Println("hello,", user.GetLogin())

	// Concurrent pagination with early-exit on the short final page.
	events, err := githubkit.FetchPages(ctx,
		githubkit.PaginationOptions{MaxPages: 10},
		func(ctx context.Context, page int) ([]*gh.Event, error) {
			events, _, err := kernel.Activity.ListEvents(ctx, &gh.ListOptions{
				Page:    page,
				PerPage: 100,
			})
			return events, err
		})
	if err != nil {
		panic(err)
	}
	fmt.Println("fetched", len(events), "events")

	// Live budget, fed from every response's rate-limit headers.
	if snapshot, ok := kernel.RateLimitSnapshot(); ok {
		fmt.Printf("rate budget: %d/%d, resets %s\n",
			snapshot.Remaining, snapshot.Limit, snapshot.ResetAt.Format("15:04:05"))
	}
}

What the stack does

Every request flows through, outermost first:

gate ──▶ feed ──▶ retry ──▶ etag ──▶ base
Layer Behavior
gate Pre-flight rate-limit check. Unknown budget → lazy GET /rate_limit probe (30s cooldown, probe failure never blocks). At or below MinRemaining (default 10) it sleeps until the reset; a reset further than MaxWait (default 15m) away fails fast with ErrRateLimited.
feed Parses X-RateLimit-* headers from every response, including failed retries, into a shared RateLimitCache.
retry 429 always retried (GitHub rejects before processing). 5xx retried only for idempotent methods (GET/HEAD/OPTIONS/PUT/DELETE) — POST is never auto-retried. Retry-After honored, capped at MaxBackoff. Default: 3 retries, 1s→30s exponential backoff. Request bodies are replayed safely.
etag Opt-in (WithETagCache). GET-only. Sends If-None-Match; a 304 becomes a synthesized 200 with the cached body and fresh rate headers, marked X-Github-Kit-From-Cache: 1. Entries are keyed by a credential fingerprint, so two tokens never share cache entries.
base Tuned http.Transport (100 idle conns, 10 per host, 90s idle timeout).

Each concern is individually disable-able: WithoutRateLimit(), WithoutRetry(). WithRateLimitOptions/WithRetryOptions override only the fields you set — partial configuration keeps sane defaults. WithBaseURL points the kernel at a GitHub Enterprise Server root (a trailing slash is appended for you, matching the SDK's own WithEnterpriseURLs).

Errors: sentinels that preserve everything

ClassifyError maps any failure to a *StatusError that wraps both a kit sentinel and the original error, so classification never destroys information:

_, _, err := kernel.Repositories.Get(ctx, owner, repo)
err = githubkit.ClassifyError(err)

if statusErr, ok := errors.AsType[*githubkit.StatusError](err); ok {
	switch {
	case errors.Is(statusErr, githubkit.ErrNotFound):
		// 404
	case errors.Is(statusErr, githubkit.ErrRateLimited):
		// 429, or 403 with X-RateLimit-Remaining: 0 (GitHub conflates them)
	case errors.Is(statusErr, githubkit.ErrAuthRequired):
		// 401
	case errors.Is(statusErr, githubkit.ErrForbidden):
		// 403 without a rate-limit cause
	case errors.Is(statusErr, githubkit.ErrAPIUnavailable):
		// ≥500, network and URL errors
	}
}

// The original SDK error survives intact:
if ghErr, ok := errors.AsType[*gh.ErrorResponse](err); ok {
	fmt.Println("server said:", ghErr.Message)
}

Status 403 is disambiguated by the rate-limit headers: with X-RateLimit-Remaining: 0 it classifies as ErrRateLimited, otherwise ErrForbidden.

The native pre-flight check

go-github runs its own rate-limit check before each request and refuses with a *gh.RateLimitError when its tracked budget hits exactly 0, using its own wall clock — the kit cannot disable or time-control that check. In practice the kit's gate (floor of 10 by default) acts first and waits instead of failing, so this only surfaces when you opt out with WithoutRateLimit(). If you do, expect native *gh.RateLimitError values from an exhausted budget rather than kit sentinels.

Design

  • Wrap, don't replace. All kernel behavior lives in http.RoundTrippers; the SDK above stays stock. Your types, your call sites, your mocks — untouched.
  • Probes stay honest. The lazy /rate_limit probe runs through its own feed+retry stack (no gate → no recursion; no ETag → no stale budget masquerading as fresh).
  • Errors as values. Sentinels for errors.Is, StatusError for errors.AsType, originals preserved for everything else.
  • Bounded concurrency everywhere. Pagination workers (default 3) and waits (MaxWait, MaxBackoff) are capped by default; long jobs degrade politely instead of hammering.
  • Clock injection. The kernel's clock is injectable for tests (stubClock in this repo's suite), so waiting behavior is tested instantly and deterministically.

Install

go get github.com/LarsArtmann/go-github-kit

Requires Go 1.26 or newer. Depends on google/go-github/v69 and nothing else.

Development

nix develop       # dev shell with Go, golangci-lint, govulncheck
nix run .#lint    # golangci-lint (see .golangci.yml)
nix run .#test    # full suite, -race
nix flake check   # build + format checks

See also: FEATURES.md (honest feature inventory), ROADMAP.md (direction), CONTRIBUTING.md, RELEASING.md (release procedure and tag integrity).

License

MIT

Documentation

Overview

Package githubkit is a thin, composable HTTP kernel on top of google/go-github/v69: authentication, rate-limit budgeting fed from X-RateLimit-* response headers, retry with exponential backoff, typed sentinel errors, conditional ETag caching, and concurrent pagination.

The kit wraps rather than replaces go-github: New returns a native [*github.Client], so every SDK method and type keeps working. All kernel behavior lives in the client's http.RoundTripper stack, which means consumers get rate limiting, retry, and header-driven budget tracking for every call — including calls made with code that has never heard of this package.

Design in one paragraph

Each concern is one small RoundTripper, composed outermost-in as: rate-limit gate → header feed → retry → ETag cache → tuned base transport. The gate consults a shared RateLimitCache that the feed layer keeps current from every response; when the cache is empty the gate lazily probes GET /rate_limit (which itself feeds the cache through its response headers). Retry only re-sends requests that are safe to repeat (429 always, since GitHub rejects before processing; 5xx only for idempotent methods). ClassifyError maps final errors onto package sentinels while preserving the underlying [*github.ErrorResponse] for errors.Is and errors.AsType.

Usage

client, err := githubkit.New(
	githubkit.WithAuthTokenFromEnv("GITHUB_TOKEN", "GH_TOKEN"),
)
// client is a *github.Client: use it exactly as before.
events, _, err := client.Activity.ListEventsPerformedByUser(ctx, "octocat", false, nil)

Index

Examples

Constants

View Source
const (
	DefaultTokenEnvGITHUB = "GITHUB_TOKEN" //nolint:gosec // env var name, not a credential
	DefaultTokenEnvGH     = "GH_TOKEN"     //nolint:gosec // env var name, not a credential
)

Default token environment variables consulted by WithAuthTokenFromEnv when called without arguments. GITHUB_TOKEN wins because it is the name GitHub's own documentation uses; GH_TOKEN is the gh CLI convention.

View Source
const DefaultETagEntries = 256

DefaultETagEntries is the cache size when ETagOptions.MaxEntries is zero.

View Source
const DefaultRequestTimeout = 30 * time.Second

DefaultRequestTimeout bounds a single HTTP round trip. The kernel's own waits (rate-limit reset, backoff) are governed by the caller's context, not this timeout.

Variables

View Source
var (
	// ErrAuthRequired marks a missing or rejected credential (HTTP 401, or
	// no token could be resolved at construction time).
	ErrAuthRequired = errors.New("github: authentication required")

	// ErrForbidden marks a 403 that is a permissions denial, not a rate
	// limit. GitHub overloads 403 for both; the kit disambiguates using
	// the X-RateLimit-Remaining header.
	ErrForbidden = errors.New("github: forbidden")

	// ErrRateLimited marks an exhausted request budget: HTTP 429, a 403
	// with zero remaining requests, or a reset time too far in the future
	// to wait for (see RateLimitOptions.MaxWait).
	ErrRateLimited = errors.New("github: rate limit exceeded")

	// ErrNotFound marks a missing resource (HTTP 404).
	ErrNotFound = errors.New("github: resource not found")

	// ErrAPIUnavailable marks transport-level failures and exhausted 5xx
	// retries: the API could not be reached or kept failing.
	ErrAPIUnavailable = errors.New("github: API unavailable")
)

Sentinel errors classifying GitHub API failures. Names align with the providererrors vocabulary used across LarsArtmann provider projects so cross-project errors.Is checks read naturally. ClassifyError produces errors that match these via errors.Is while preserving the underlying go-github error for errors.AsType.

View Source
var DefaultRateLimitOptions = RateLimitOptions{
	Enabled:      true,
	MinRemaining: defaultMinRemaining,
	MaxWait:      defaultMaxWait,
}

DefaultRateLimitOptions gates when ten or fewer core requests remain in the window, waiting for the reset for at most fifteen minutes. Beyond that, calls fail fast with ErrRateLimited instead of blocking a caller for an unbounded time.

View Source
var DefaultRetryOptions = RetryOptions{
	Enabled:        true,
	MaxRetries:     defaultMaxRetries,
	InitialBackoff: defaultInitialBackoff,
	MaxBackoff:     defaultMaxBackoff,
}

DefaultRetryOptions retries up to three times with exponential backoff growing from one second to at most thirty seconds. These bounds match GitHub's guidance for secondary rate limits and transient 5xx storms.

View Source
var ErrInvalidPagination = errors.New("githubkit: PaginationOptions.MaxPages must be at least 1")

ErrInvalidPagination is returned by FetchPages when MaxPages is not at least 1. An unbounded walk is never the right default: a misbehaving server that always returns full pages would make it infinite.

Functions

func ClassifyError

func ClassifyError(err error) error

ClassifyError maps an error returned by a go-github call to a *StatusError matching a kit sentinel. The original error is preserved: errors.AsType[*github.ErrorResponse] and errors.Is against go-github's own sentinels still succeed on the result. nil classifies to nil.

Errors that match no category (e.g. HTTP 400) are returned unchanged — forcing them under a wrong sentinel would be a lie.

Example

ClassifyError maps any call failure to a StatusError wrapping both a kit sentinel (for errors.Is) and the original error (for errors.AsType), so classification never destroys information.

package main

import (
	"context"
	"errors"
	"fmt"
	"net/http"
	"net/http/httptest"
	"strconv"
	"time"

	githubkit "github.com/LarsArtmann/go-github-kit"
	gh "github.com/google/go-github/v69/github"
)

// rateHeaderMiddleware stamps healthy budget headers on every response so
// the kernel's gate never blocks the example flow, and answers the lazy
// /rate_limit probe.
func rateHeaderMiddleware(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		w.Header().Set("X-RateLimit-Limit", "5000")
		w.Header().Set("X-RateLimit-Remaining", "4999")
		w.Header().Set("X-RateLimit-Reset", strconv.FormatInt(time.Now().Add(time.Hour).Unix(), 10))

		if r.URL.Path == "/rate_limit" {
			w.Header().Set("Content-Type", "application/json")
			w.WriteHeader(http.StatusOK)
			return
		}

		next.ServeHTTP(w, r)
	})
}

func main() {
	server := httptest.NewServer(rateHeaderMiddleware(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
		w.Header().Set("Content-Type", "application/json")
		w.WriteHeader(http.StatusNotFound)
		_, _ = w.Write([]byte(`{"message": "Not Found"}`))
	})))
	defer server.Close()

	kernel, err := githubkit.New(
		githubkit.WithPAT("ghp_example"),
		githubkit.WithBaseURL(server.URL),
	)
	if err != nil {
		fmt.Println("construction failed:", err)
		return
	}

	_, _, err = kernel.Repositories.Get(context.Background(), "LarsArtmann", "does-not-exist")

	classified := githubkit.ClassifyError(err)

	if statusErr, ok := errors.AsType[*githubkit.StatusError](classified); ok {
		if errors.Is(statusErr, githubkit.ErrNotFound) {
			fmt.Println("no such repository")
		}
	}

	if ghErr, ok := errors.AsType[*gh.ErrorResponse](err); ok {
		fmt.Println("server said:", ghErr.Message)
	}

}
Output:
no such repository
server said: Not Found

func FetchPages

func FetchPages[T any](
	ctx context.Context,
	opts PaginationOptions,
	fetch func(ctx context.Context, page int) ([]T, error),
) ([]T, error)

FetchPages walks a paginated GitHub list endpoint concurrently.

Page 1 is fetched alone: it decides whether the walk is worthwhile and warms the rate-limit budget from its headers. Pages 2 through MaxPages then run through a bounded worker pool (PaginationOptions.Concurrency, default 3). The moment any page comes back short — fewer items than PerPage — every page beyond it is skipped or cancelled, because GitHub only returns short pages at the end of a collection. Results are returned in page order regardless of completion order.

The fetch function receives the caller's context, cancelled when the walk ends early; a fetch that fails with context.Canceled after the short page was seen is treated as a successful skip, not an error.

The per-page rate gate applies automatically when fetch goes through a Kernel, since each page is an ordinary request through the kernel stack.

Example

FetchPages walks a paginated endpoint concurrently: page 1 alone, then pages 2..N through a bounded worker pool, stopping at the first short page.

package main

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

	githubkit "github.com/LarsArtmann/go-github-kit"
	gh "github.com/google/go-github/v69/github"
)

// rateHeaderMiddleware stamps healthy budget headers on every response so
// the kernel's gate never blocks the example flow, and answers the lazy
// /rate_limit probe.
func rateHeaderMiddleware(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		w.Header().Set("X-RateLimit-Limit", "5000")
		w.Header().Set("X-RateLimit-Remaining", "4999")
		w.Header().Set("X-RateLimit-Reset", strconv.FormatInt(time.Now().Add(time.Hour).Unix(), 10))

		if r.URL.Path == "/rate_limit" {
			w.Header().Set("Content-Type", "application/json")
			w.WriteHeader(http.StatusOK)
			return
		}

		next.ServeHTTP(w, r)
	})
}

func main() {
	mux := http.NewServeMux()
	mux.HandleFunc("/events", func(w http.ResponseWriter, r *http.Request) {
		w.Header().Set("Content-Type", "application/json")

		switch r.URL.Query().Get("page") {
		case "", "1":
			_, _ = w.Write([]byte(`[{"id": "e1"}, {"id": "e2"}]`))
		case "2":
			_, _ = w.Write([]byte(`[{"id": "e3"}, {"id": "e4"}]`))
		default:
			_, _ = w.Write([]byte(`[{"id": "e5"}]`))
		}
	})

	server := httptest.NewServer(rateHeaderMiddleware(mux))
	defer server.Close()

	kernel, err := githubkit.New(
		githubkit.WithPAT("ghp_example"),
		githubkit.WithBaseURL(server.URL),
	)
	if err != nil {
		fmt.Println("construction failed:", err)
		return
	}

	events, err := githubkit.FetchPages(context.Background(),
		githubkit.PaginationOptions{MaxPages: 10, PerPage: 2, Concurrency: 1},
		func(ctx context.Context, page int) ([]*gh.Event, error) {
			events, _, err := kernel.Activity.ListEvents(ctx, &gh.ListOptions{Page: page, PerPage: 2})
			return events, err
		})
	if err != nil {
		fmt.Println("walk failed:", err)
		return
	}

	fmt.Println("fetched", len(events), "events")

}
Output:
fetched 5 events

Types

type ETagCache

type ETagCache struct {
	// contains filtered or unexported fields
}

ETagCache is the kernel's conditional GET cache: a policy wrapper over etagclient.Transport with credential-scoped keys, rate-limit header preservation, and the kit's from-cache marker.

func NewETagCache

func NewETagCache(opts ETagOptions) *ETagCache

NewETagCache creates a cache honoring opts (zero fields defaulted). Call wrap to place its transport into a RoundTripper stack; Stats reports counters once wrapped.

func (*ETagCache) Stats

func (c *ETagCache) Stats() ETagStats

Stats returns current counters, zero before the first wrap.

type ETagOptions

type ETagOptions struct {
	// MaxEntries bounds the cache. Zero means 256. When full, the oldest
	// entry is evicted (FIFO).
	MaxEntries int

	// MaxBodyBytes is the largest response body cached. Zero means 8 MiB,
	// sized for GitHub's larger list payloads; oversized responses pass
	// through uncached with their bodies intact.
	MaxBodyBytes int
}

ETagOptions configures the client-side conditional GET cache.

GitHub's REST API returns strong ETags on most GET responses. Replaying them as If-None-Match turns unchanged re-fetches into free 304s — one request spent, zero budget counted against the data rate limits — and the kernel serves the cached body to go-github as if it were a 200, so callers see no difference. The generic mechanism lives in github.com/larsartmann/go-etag/client; this type carries only GitHub policy on top of it.

type ETagStats

type ETagStats = etagclient.Stats

ETagStats reports conditional-cache activity. It aliases the counters of the underlying etagclient transport: Hits (304s served from cache), Stored (200s added to the cache), and Entries (currently cached responses).

type Kernel

type Kernel struct {
	*gh.Client
	// contains filtered or unexported fields
}

Kernel is a go-github client plus the kit's shared state. It embeds [*github.Client], so every native SDK method works directly on a Kernel, and code written against *github.Client compiles unchanged when handed k.Client.

A Kernel is safe for concurrent use. Construct with New.

func New

func New(opts ...Option) (*Kernel, error)

New builds a Kernel: a native go-github client whose HTTP stack applies, outermost first, the rate-limit gate, header-fed budget tracking, retry with backoff, and (optionally) ETag conditional caching, over a tuned base transport.

By default the gate is on (wait up to 15m when ≤10 requests remain, otherwise fail fast with ErrRateLimited) and retry is on (3 retries, 1s→30s exponential backoff on 429 and 5xx for idempotent requests). Authentication is opt-in via WithPAT or WithAuthTokenFromEnv.

Example

Construct a kernel from the environment. WithAuthTokenFromEnv tries each variable in order and fails with ErrAuthRequired when none is set, reporting every name it tried.

package main

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

	githubkit "github.com/LarsArtmann/go-github-kit"
)

// rateHeaderMiddleware stamps healthy budget headers on every response so
// the kernel's gate never blocks the example flow, and answers the lazy
// /rate_limit probe.
func rateHeaderMiddleware(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		w.Header().Set("X-RateLimit-Limit", "5000")
		w.Header().Set("X-RateLimit-Remaining", "4999")
		w.Header().Set("X-RateLimit-Reset", strconv.FormatInt(time.Now().Add(time.Hour).Unix(), 10))

		if r.URL.Path == "/rate_limit" {
			w.Header().Set("Content-Type", "application/json")
			w.WriteHeader(http.StatusOK)
			return
		}

		next.ServeHTTP(w, r)
	})
}

func main() {
	server := httptest.NewServer(rateHeaderMiddleware(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
		w.Header().Set("Content-Type", "application/json")
		_, _ = w.Write([]byte(`{"login": "octocat", "id": 583231}`))
	})))
	defer server.Close()

	// Production code resolves the token from the environment:
	// githubkit.WithAuthTokenFromEnv(). The example uses a fake PAT.
	kernel, err := githubkit.New(
		githubkit.WithPAT("ghp_example"),
		githubkit.WithBaseURL(server.URL),
	)
	if err != nil {
		fmt.Println("construction failed:", err)
		return
	}

	user, _, err := kernel.Users.Get(context.Background(), "")
	if err != nil {
		fmt.Println("request failed:", err)
		return
	}

	fmt.Println("hello,", user.GetLogin())

}
Output:
hello, octocat

func (*Kernel) ETagStats

func (k *Kernel) ETagStats() (ETagStats, bool)

ETagStats reports conditional-cache counters: hits (304 revalidations served from cache), misses (requests sent with a validator), and stored entries. The second return is false when WithETagCache was not used.

func (*Kernel) RateLimitSnapshot

func (k *Kernel) RateLimitSnapshot() (RateLimitSnapshot, bool)

RateLimitSnapshot returns the freshest known core budget, fed from the X-RateLimit-* headers of every response the Kernel has seen. The second return is false when nothing has been observed yet.

func (*Kernel) RefreshRateLimit

func (k *Kernel) RefreshRateLimit(ctx context.Context) (RateLimitSnapshot, error)

RefreshRateLimit forces a GET /rate_limit round trip against the client's base URL and returns the resulting budget. The gate performs this probe automatically when it has no cached data; consumers may call it to warm the cache before a burst.

type Option

type Option func(*Options)

Option configures New.

func WithAuthTokenFromEnv

func WithAuthTokenFromEnv(vars ...string) Option

WithAuthTokenFromEnv resolves the token from the first environment variable that is set and non-empty. Called without arguments it consults GITHUB_TOKEN, then GH_TOKEN. When no variable is set, New fails with an error wrapping ErrAuthRequired naming the variables it tried, so a missing token is a typed, actionable failure instead of 401s at request time.

Example

The environment fallback fails with a typed, actionable error.

package main

import (
	"errors"
	"fmt"
	"os"

	githubkit "github.com/LarsArtmann/go-github-kit"
)

func main() {
	_ = os.Unsetenv("GITHUB_TOKEN")
	_ = os.Unsetenv("GH_TOKEN")

	_, err := githubkit.New(githubkit.WithAuthTokenFromEnv())
	fmt.Println(errors.Is(err, githubkit.ErrAuthRequired))

}
Output:
true

func WithBaseURL

func WithBaseURL(rawURL string) Option

WithBaseURL points the client at a different API root, e.g. a GitHub Enterprise Server ("https://github.example.com/api/v3") or an httptest server. A missing trailing slash is appended automatically, matching go-github's own WithEnterpriseURLs behavior.

func WithETagCache

func WithETagCache(opts *ETagOptions) Option

WithETagCache enables the client-side conditional GET cache: response ETags are replayed as If-None-Match and 304 responses are served from the in-memory cache, transparently to go-github. opts may be nil for defaults.

func WithHTTPClient

func WithHTTPClient(client *http.Client) Option

WithHTTPClient supplies a custom base HTTP client. Its Transport becomes the innermost kernel layer, so custom proxies, mocks, or instruments keep working; its timeout is adopted unless WithTimeout overrides it.

func WithPAT

func WithPAT(token string) Option

WithPAT authenticates with an explicit Personal Access Token. For environment-based resolution see WithAuthTokenFromEnv.

func WithRateLimitOptions

func WithRateLimitOptions(opts RateLimitOptions) Option

WithRateLimitOptions overrides the rate-limit gate configuration. Zero-valued fields keep their defaults; the gate stays enabled.

func WithRetryOptions

func WithRetryOptions(opts RetryOptions) Option

WithRetryOptions overrides retry behavior. Zero-valued fields keep their defaults; retry stays enabled.

func WithTimeout

func WithTimeout(d time.Duration) Option

WithTimeout bounds a single HTTP round trip. Zero-value options use DefaultRequestTimeout. Waits inside the kernel (rate-limit reset, backoff sleeps) are not bounded by this; they follow the call's context.

func WithoutRateLimit

func WithoutRateLimit() Option

WithoutRateLimit disables the pre-flight gate and lazy /rate_limit probes. Response headers still feed the cache, so Kernel.RateLimitSnapshot remains available for observability.

func WithoutRetry

func WithoutRetry() Option

WithoutRetry disables retries entirely: each call makes exactly one round trip.

type Options

type Options struct {
	// Token authenticates every request as a GitHub Personal Access Token.
	// Empty means unauthenticated (60 req/h budget).
	Token string

	// TokenEnvVars lists environment variables to consult when Token is
	// empty; the first variable that is set wins. Nil disables env lookup.
	TokenEnvVars []string

	// BaseURL overrides the GitHub API base URL (GitHub Enterprise Server,
	// httptest servers). A missing trailing slash is appended automatically,
	// matching the native SDK's WithEnterpriseURLs behavior.
	BaseURL string

	// HTTPClient, when set, contributes its Transport as the innermost
	// layer and its Timeout as the per-request timeout; the kernel wraps
	// rather than replaces it.
	HTTPClient *http.Client

	// RequestTimeout bounds one round trip. Zero means [DefaultRequestTimeout].
	RequestTimeout time.Duration

	// RateLimit configures the pre-flight gate. Zero-value fields take
	// [DefaultRateLimitOptions] values.
	RateLimit RateLimitOptions

	// Retry configures the retry layer. Zero-value fields take
	// [DefaultRetryOptions] values.
	Retry RetryOptions

	// ETag, when non-nil, enables the conditional GET cache with the given
	// settings.
	ETag *ETagOptions
	// contains filtered or unexported fields
}

Options is the fully resolved configuration for New. The zero value is not used directly; New applies defaults before running the given Option functions.

type PaginationOptions

type PaginationOptions struct {
	// MaxPages is the hard cap on pages fetched; it must be at least 1.
	// GitHub caps list endpoints at 1000 pages (300 items/page for some),
	// so callers bound by their domain, not by this default.
	MaxPages int

	// PerPage is the expected page size used to detect the final short
	// page. Zero means 100, GitHub's documented maximum.
	PerPage int

	// Concurrency is how many of pages 2..MaxPages may be in flight at
	// once. Zero means 3; 1 makes the walk sequential.
	Concurrency int

	// OnProgress, when set, is invoked after each page completes with the
	// page number, the page cap, and the cumulative item count so far.
	OnProgress func(page, totalPages, cumulative int)
}

PaginationOptions tunes FetchPages.

type RateLimitCache

type RateLimitCache struct {
	// contains filtered or unexported fields
}

RateLimitCache stores the last observed core budget. It is safe for concurrent use and shared by every layer of one Kernel.

The zero value is not usable; construct via NewRateLimitCache.

func NewRateLimitCache

func NewRateLimitCache() *RateLimitCache

NewRateLimitCache creates an empty cache.

func (*RateLimitCache) Decrement

func (c *RateLimitCache) Decrement(n int)

Decrement subtracts n from the remaining count after dispatching requests, keeping the estimate conservative between authoritative responses. It can only shrink the value to zero.

func (*RateLimitCache) Get

Get returns the cached budget and whether one exists.

func (*RateLimitCache) Update

func (c *RateLimitCache) Update(snapshot RateLimitSnapshot)

Update stores the authoritative budget from an API response. Snapshots with a zero Limit are ignored: GitHub sends them only on responses that do not participate in the core budget (e.g. redirects), and overwriting good data with them would make the gate fly blind.

type RateLimitOptions

type RateLimitOptions struct {
	// Enabled turns the gate on. Defaults to true; [WithoutRateLimit] is
	// the explicit opt-out.
	Enabled bool

	// MinRemaining is the budget floor: when at or below this many
	// remaining requests, the gate waits for the window to reset.
	// Defaults to 10.
	MinRemaining int

	// MaxWait bounds how long the gate may wait for a reset. A reset
	// further away fails fast with [ErrRateLimited] instead. Defaults to
	// 15 minutes.
	MaxWait time.Duration
}

RateLimitOptions configures the pre-flight gate. New starts from DefaultRateLimitOptions, and WithRateLimitOptions overrides only the fields it sets to non-zero values, so partial overrides keep sane defaults for the rest.

type RateLimitSnapshot

type RateLimitSnapshot struct {
	// Limit is the total requests allowed per window.
	Limit int
	// Remaining is the requests left in the current window.
	Remaining int
	// ResetAt is when the window resets.
	ResetAt time.Time
}

RateLimitSnapshot is the core request budget for one window, as GitHub reports it in X-RateLimit-* response headers.

func ParseRateLimitHeaders

func ParseRateLimitHeaders(header rateLimitHeaderSource) (RateLimitSnapshot, bool)

ParseRateLimitHeaders extracts the core budget from response headers. GitHub spells the header family "X-Ratelimit-*" (lowercase "l"), and both spellings are accepted because proxies in the wild have been seen normalizing the casing. ok is false when Limit or Remaining is absent or unparseable — callers treat that as "no information", never as zero budget, because acting on a misread header would stall healthy traffic.

type RetryOptions

type RetryOptions struct {
	// Enabled turns retry on. Defaults to true; [WithoutRetry] is the
	// explicit opt-out.
	Enabled bool

	// MaxRetries is the number of additional attempts after the first.
	// Defaults to 3.
	MaxRetries int

	// InitialBackoff is the delay before the first retry. Defaults to 1s.
	InitialBackoff time.Duration

	// MaxBackoff caps the exponential growth and any Retry-After honoring.
	// Defaults to 30s.
	MaxBackoff time.Duration
}

RetryOptions configures the retry layer. New starts from DefaultRetryOptions; WithRetryOptions overrides only non-zero fields.

type StatusError

type StatusError struct {
	// Sentinel is the matching kit sentinel, one of ErrAuthRequired,
	// ErrForbidden, ErrRateLimited, ErrNotFound, or ErrAPIUnavailable.
	Sentinel error
	// Status is the HTTP status code, or 0 for transport errors.
	Status int
	// Method and URL identify the request that failed.
	Method string
	URL    string
	// contains filtered or unexported fields
}

StatusError classifies an error from a GitHub API call. It wraps both a kit sentinel (for errors.Is) and the original error (for errors.AsType and error-message detail), so classifying never destroys information.

func (*StatusError) Error

func (e *StatusError) Error() string

Error implements the error interface with the request context first and the underlying message last, so log lines read like "github: GET https://api.github.com/users/x/events: 404 resource not found: not found, users/x".

func (*StatusError) Unwrap

func (e *StatusError) Unwrap() []error

Unwrap exposes both the sentinel and the underlying error so errors.Is and errors.AsType (notably [*github.ErrorResponse]) keep working on classified errors.

Jump to

Keyboard shortcuts

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