Skip to content

Errors

Every sentinel forge exports, what returns it, and what a caller should do about it.

All errors are created and wrapped with cockroachdb/errors, so compare with errors.Is and never with == or by matching the message.

The sentinels at a glance

Sentinel Package Meaning Do
ErrNotSupported forge This operation does not apply to this backend, or is configured off Fall back
ErrNotFound forge The requested thing is not there Treat as an ordinary answer
ErrReleaseNotFound forge The release does not exist Treat as an ordinary answer
ErrUnauthorized forge The credential is not valid: absent, expired, revoked Re-authenticate. The only sentinel that authorises discarding a cached connection
ErrForbidden forge The credential is valid and lacks permission Give up on this request. Never re-resolve the credential
ErrRateLimited forge The caller is being throttled Back off. forge.RetryAfter says how long. Never re-resolve the credential
ErrProviderNotFound forge No factory registered for that source type Fix the wiring; usually a missing blank import
ErrAlreadyRegistered forge A factory is already registered for that source type Fix the wiring, or Unregister first
ErrStaleAuthKeys forge Config still carries auth.env or auth.keychain Migrate the configuration
ErrProviderPanic forge The provider's client panicked and was converted to an error Do not retry the same request
ErrVersionUnknown forge/direct Neither pinned_version nor version_url is configured Configure one

ErrNotSupported: fall back, do not fail

Returned by a provider method the platform has no equivalent for, and by an optional capability that is configured off. ListReleases on Bitbucket Downloads and on direct are the standard examples; so is DownloadChecksumManifest when checksum_url_template is empty.

It must be treated identically to "the provider does not implement the interface". Both mean fall back, and a caller that can tell them apart is depending on something the contract does not promise:

manifest, err := cp.DownloadChecksumManifest(ctx, rel, forge.DefaultMaxChecksumsSize)

switch {
case err == nil:
    // use it
case errors.Is(err, forge.ErrNotSupported):
    // fall back to locating checksums.txt by name
default:
    return err // a real failure
}

Returning a bespoke error where this sentinel belongs turns a recoverable fallback into a hard failure, and the caller has no way to know it was meant to be recoverable. The conformance harness checks for it.

ErrNotFound: "no" is an answer, "I could not check" is not

Returned by Contents.GetFile for a file that does not exist at the ref, by Sites.GetSite for a repository with no site, and by Issues.GetIssue for an issue that does not exist.

The distinction it carries is the point. "This repository does not have a zensical.toml" and "I could not find out" demand opposite handling:

data, err := contents.GetFile(ctx, owner, repo, "zensical.toml", ref, forge.DefaultMaxFileSize)
switch {
case errors.Is(err, forge.ErrNotFound):
    return false, nil // does not qualify — not an error
case err != nil:
    return false, err // could not check — retry, do NOT exclude
}

A permission failure is never ErrNotFound. A caller must not be told a thing is absent because its token was too weak.

This sentinel ships with a conformance check, so errors.Is against it is reliable across the first-party providers.

ErrReleaseNotFound: a missing release, specifically

errors.Is(err, forge.ErrReleaseNotFound) is reliable across the first-party set, and the conformance harness holds every provider to it: a tag that does not resolve must come back carrying this sentinel rather than the platform's own error text.

Which not-found sentinel a 404 carries is the provider's decision, because what is missing depends on what was addressed. GitLab reports a 404 from its release listing as ErrNotFound, because a missing project is what that status means there, and reserves ErrReleaseNotFound for a project that has no releases yet. Bitbucket Downloads has no release concept to miss at all. Branch on both if you only care that something was absent.

The refusal sentinels

A forge refuses a request in three distinguishable ways, and they want opposite responses. Reacting to all of them identically is wrong in two of the three cases, and one of those two does active harm.

Sentinel Typically Re-resolve the credential?
ErrUnauthorized HTTP 401 Yes, the credential really is bad
ErrForbidden HTTP 403 that is not a rate limit No
ErrRateLimited HTTP 429, or a 403 carrying rate-limit evidence No

ErrUnauthorized is the only one of the three that justifies discarding a cached connection and building a new one. See Reuse a connection for the mechanism.

Re-resolving on ErrForbidden produces the same valid credential, refused again, which prompts another invalidation. The loop is silent and feeds itself.

Re-resolving on ErrRateLimited is worse. A consumer reusing providers resolves credentials concurrently when invalidated, so invalidating here aims a burst of resolutions at an API that is already refusing. That is how a throttle becomes a lockout.

GitHub answers a rate limit with 403

Not 429. A caller (or a provider) that maps statuses in order and stops at the first match reports a throttle as a permission failure, which is exactly the confusion the table above exists to prevent. Every first-party provider settles rate limiting before permission, and the conformance harness fails one that does not.

RetryAfter, and why the bool matters

if delay, ok := forge.RetryAfter(err); ok {
    time.Sleep(delay)
}

The bool is the load-bearing half. A forge that gives no reset time reports false rather than a zero duration, so "it did not say" can never be read as "retry now". Against an API already throttling you, that is the worst available response.

The duration is capped at forge.MaxRetryAfter (one hour). A retry delay is server-controlled input: without a bound, a broken instance sending Retry-After: 999999999 stalls the caller for eleven days. A caller that genuinely wants to wait longer reads the platform's own error out of the chain and decides for itself.

The platform's error survives

Every sentinel is added to the chain, never substituted for what the forge said. errors.Is answers "which refusal"; errors.As still reaches the SDK's own error for the detail:

var ghErr *github.ErrorResponse
if errors.As(err, &ghErr) {
    // GitHub's own message, status and documentation URL
}

ErrProviderNotFound and ErrAlreadyRegistered: registry wiring

Lookup returns ErrProviderNotFound when no factory is registered for a source type. Its hint lists what is registered, which usually identifies the real problem, a missing blank import:

_ "gitlab.com/phpboyscout/go/forge-gitlab"

Register returns ErrAlreadyRegistered rather than overwriting, because a silent overwrite would let one blank import displace another with initialisation order picking the winner. A provider module registering from init() should panic at its own call site, where the message names the module at fault. Code building a registry programmatically handles the error instead, which is why Register does not panic on anyone's behalf.

Use forge.Registered(name) to register conditionally, and forge.Unregister to replace a provider deliberately.

ErrStaleAuthKeys: migrated in code but not on disk

Returned by ConfigCredential when the configured key yields nothing and auth.env or auth.keychain is still present. See configuration for the migration.

It is deliberately not fatal on its own. FirstCredential retains it and returns it only when no source produced a credential, so a stale key next to a working environment variable does not fail a deployment that works.

ErrProviderPanic: the fault is in a dependency

forge.GuardPanic and GuardPanicValue convert a panic from a forge SDK into an error wrapping this sentinel, with the stack attached as a detail.

You can branch on it, but the useful response is much the same as for any other provider failure. What it tells you that an ordinary error does not is that the fault is in a dependency's response handling rather than in the request. Retrying the same request will probably panic again.

Panics Go itself declines to make recoverable (a concurrent map write, an out-of-memory throw) are unaffected, because recover does not see them.

direct.ErrVersionUnknown: a provider-local sentinel

Returned by GetLatestRelease on the direct provider when neither pinned_version nor version_url is configured.

It lives in the direct package rather than the shared contract on purpose: it names configuration keys that mean nothing to a forge-backed provider, and a sentinel in the shared package would imply every provider might return it.

Reading the hint attached to an error

Several of these carry a user-facing hint: the registered source types, which stale key was found, what to set instead. Surface it rather than only the message.

if hints := errors.GetAllHints(err); len(hints) > 0 {
    fmt.Fprintln(os.Stderr, strings.Join(hints, "\n"))
}

errors.FlattenHints(err) gives the same content as a single string. GetAllDetails / FlattenDetails do the same for details, which is where GuardPanic puts the stack.

Which errors a provider must not swallow

Two failures matter more than the rest, because a caller reading them wrongly does something irreversible:

  • A partial enumeration. ListRepositories and SearchIssues return an error that is the complete answer to "did I see everything?". A non-nil error means a partial view. CollectRepositories returns what it gathered alongside the error precisely so you can see how far it got. Never treat that slice as the namespace.
  • A failed duplicate search. A search that fails and is read as "nothing matched" files a duplicate issue into a public tracker. An empty result set and a failed search are different answers.