Skip to content

Handle failures

Match with errors.Is rather than on message text. There are ten sentinels, and the distinctions between them are the ones worth acting on.

path, err := client.Resolve(ctx, ref, file)

switch {
case errors.Is(err, artifacts.ErrWithdrawn):
    // the channel has withdrawn this version — STOP, and escalate

case errors.Is(err, artifacts.ErrNotListed):
    // not in the approved index; never approved, or the index moved on

case errors.Is(err, artifacts.ErrIndexUnavailable):
    // no approval decision could be made — nothing was checked; RETRY

case errors.Is(err, artifacts.ErrStaleIndex):
    // the index verified but is too old to act on — RETRY

case errors.Is(err, artifacts.ErrMalformedIndex):
    // correctly signed, but not an index — the channel is broken

case errors.Is(err, artifacts.ErrTrustUnavailable):
    // the anchors could not be established — nothing was checked; RETRY

case errors.Is(err, artifacts.ErrUnverified):
    // the bytes arrived and could not be trusted — do NOT retry

case errors.Is(err, artifacts.ErrIdentityMismatch):
    // correctly signed, for something else — substitution or replay

case errors.Is(err, artifacts.ErrNotFound):
    // no such artefact-version, or no such file in it

case errors.Is(err, artifacts.ErrUnsigned):
    // the version exists and its publication did not finish

case errors.Is(err, artifacts.ErrMalformedManifest):
    // correctly signed, but not a manifest — the channel is broken

case err != nil:
    // transport, cache, or configuration
}

What each one means for your next move

ErrWithdrawn — the channel has withdrawn this artefact-version. Its signature and digest are still perfectly valid; what changed is its standing.

This is the one to escalate. A withdrawal means somebody decided the artefact should not be used — a compromised upstream release, a serious defect — and the right response is to find out why, not to retry or pin around it.

It is terminal. A withdrawn version never returns, because a correction ships as a new version. Retrying will not help and neither will waiting.

ErrNotListed — the index does not mention this artefact-version. The index is a complete statement of what is approved, so absence means not approved rather than not mentioned. Either it was never approved, or you are asking for something the channel has moved past.

ErrIndexUnavailable — the index could not be fetched or verified, so no approval decision could be made. Retry, bounded, under a context. Nothing is known about the artefact because nothing was consulted.

Note what this makes true: the index host is a hard dependency of every resolution. That is deliberate — a check that is skipped when unreachable is skipped in exactly the conditions an attacker can create — but it is a real availability cost rather than a footnote.

ErrStaleIndex — the index verified and is too old to act on: past its expiry, or older than one this client has already accepted.

A signature proves an index was issued, not that it is current. This is the error that stops a replayed index restoring a withdrawn version, so it is security-relevant even though it usually means the publisher's schedule slipped. Retryable — a fresh index will be along.

ErrMalformedIndex — the signature was good and the contents are not an index. Like ErrMalformedManifest, an operational fault at the publisher rather than an attack.

ErrTrustUnavailable — the embedded key and the WKD-served key could not be brought into agreement, so the signature was never actually checked. Retry this. Nothing is known about the artefact, because nothing about it was examined. A captive portal, an intercepting proxy or a DNS failure all land here.

Bounded retries under a context, not a loop: if the anchors stay unreachable the answer will not change, and the channel is fail-closed on purpose.

ErrUnverified — either the manifest's signature did not verify, or the artefact's digest or length did not match the signed manifest. These are deliberately the same error. A failed signature and mismatched bytes lead to the same response — do not use this — and a caller branching on the difference would be making a decision it has no business making.

Do not retry. Nothing about a second attempt makes untrusted bytes trustworthy, and a retry loop against a hostile channel is a request amplifier.

ErrIdentityMismatch — the manifest is genuine and describes a different artefact-version. That is the signature of substitution or replay, and it is neither a bad signature nor a missing file. Treat it as a security event: the channel served something the publisher signed for another purpose.

ErrNotFound — the channel has no such artefact-version, or the file is not in that version's manifest. Usually a typo or a platform that was never published. Not retryable; the answer will not change until someone approves a version.

ErrUnsigned — the manifest is there and the signature is not. The version exists; the publication did not finish. That is the publisher's problem rather than the caller's, and it is worth reporting rather than retrying.

ErrMalformedManifest — the signature was good and the contents are not a manifest. Parsing happens only after the signature is accepted, so this means the publisher signed something malformed. An operational fault in the channel, not an attack, and it wants a different response: report it, don't treat it as a security event.

Anything else — a transport error, an unexpected HTTP status, a cache that could not be written. Retryable in the ordinary way.

Failures you will only see once

Two errors are configuration mistakes rather than conditions, and both surface immediately:

  • Building a client without a verifier — New(nil, …) errors.
  • Resolving without a cache — refused before anything is fetched. There is nowhere to put the result, and that is knowable before the work starts.
  • A Ref or filename that breaks the identifier grammar — ErrInvalidIdentifier, raised before the network is touched.

Testing your handling

You do not need a key or the network. Serve a channel from httptest and stub the verifier:

type stub struct{ ok bool }

func (s stub) Verify(context.Context, []byte, []byte) error {
    if !s.ok {
        return errors.New("not a trusted key")
    }
    return nil
}

The interesting case is a channel that serves a correctly signed manifest beside bytes it does not describe — signature good, digest wrong. That is the gap between "who published this" and "is this what they published", and it is the one a resolver that only checked signatures would walk straight into:

// manifest built from one set of bytes...
served := contents(map[string][]byte{"lib.tgz": []byte("described")}, true)
// ...different bytes served under the same name
served["lib.tgz"] = []byte("delivered")

_, err := client.Resolve(ctx, ref, "lib.tgz")
// errors.Is(err, artifacts.ErrUnverified)

Withdrawal reaches a warm cache

A withdrawn version is refused even if you already have it. The index is consulted before the cache is, so holding the artefact locally does not outlive the decision to withdraw it — which matters, because the population holding an artefact is exactly the population a withdrawal is aimed at.

The latency is bounded rather than instant: a client with a still-valid index keeps using it until it expires. With a 24-hour lifetime that is the worst case, plus propagation. Shortening it means republishing more often.

Ordering you can rely on

The signature over the manifest is checked before the artefact is fetched. An artefact whose publisher cannot be established is never downloaded at all, so a caller cannot end up holding unverified bytes because a later step failed.

A cache hit is checked too. Bytes are verified on the way out as well as on the way in, against a digest from a manifest verified in the same call — so a resolution never returns an entry on the strength of it having been verified once, at some point, by something.

This is a contract, not an implementation detail — if you are reasoning about what your tool has touched at the point an error is returned, you can rely on it.