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¶
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:
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.
ListRepositoriesandSearchIssuesreturn an error that is the complete answer to "did I see everything?". A non-nil error means a partial view.CollectRepositoriesreturns 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.
Related¶
- Configuration: the keys and defaults these errors refer to
- Conformance harness: which of these rules are checked mechanically
- Optional capabilities: why
ErrNotSupportedand a failed type assertion mean the same thing - What forge does not do: the limits behind several of these sentinels