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 ¶
- Constants
- Variables
- func ClassifyError(err error) error
- func FetchPages[T any](ctx context.Context, opts PaginationOptions, ...) ([]T, error)
- type ETagCache
- type ETagOptions
- type ETagStats
- type Kernel
- type Option
- func WithAuthTokenFromEnv(vars ...string) Option
- func WithBaseURL(rawURL string) Option
- func WithETagCache(opts *ETagOptions) Option
- func WithHTTPClient(client *http.Client) Option
- func WithPAT(token string) Option
- func WithRateLimitOptions(opts RateLimitOptions) Option
- func WithRetryOptions(opts RetryOptions) Option
- func WithTimeout(d time.Duration) Option
- func WithoutRateLimit() Option
- func WithoutRetry() Option
- type Options
- type PaginationOptions
- type RateLimitCache
- type RateLimitOptions
- type RateLimitSnapshot
- type RetryOptions
- type StatusError
Examples ¶
Constants ¶
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.
const DefaultETagEntries = 256
DefaultETagEntries is the cache size when ETagOptions.MaxEntries is zero.
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 ¶
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") // 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.
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.
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.
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 ¶
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.
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
func (c *RateLimitCache) Get() (RateLimitSnapshot, bool)
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.