rtb-forge 0.8.1

Release-provider abstractions and backends (GitHub, GitLab, Bitbucket, Gitea, Codeberg, Direct) plus an async git-operations Repo built on gix. Part of the phpboyscout Rust toolkit.
Documentation
---
title: ProviderError variants
description: Every release-provider failure variant, its miette diagnostic code, the HTTP status that produces it, and how to recover.
tags: [reference, errors]
---

# `ProviderError` variants

Every `ReleaseProvider` method returns `Result<_, ProviderError>`. The enum is
`#[non_exhaustive]` (match with a `_` arm) and derives `Clone` so an error can
be stashed in a retry policy or a rendered diagnostic without losing the
underlying `io::Error`.

## How an HTTP response becomes an error

Every REST backend routes non-2xx responses through one shared mapping, in this
order:

| Condition | Variant |
|---|---|
| status is 2xx | `Ok(())` |
| status is `429 Too Many Requests` | `RateLimited` |
| GitHub only: `403` **and** `X-RateLimit-Remaining: 0` | `RateLimited` |
| status is `401 Unauthorized` | `Unauthorized` |
| status is `404 Not Found` | `NotFound` |
| anything else | `Transport` |

The last row is the one that surprises people: **a plain `403 Forbidden`, a
`500`, and a `502` all arrive as `Transport`**, not as `Unauthorized` or a
dedicated server-error variant. A GitHub token with insufficient scopes
produces a `403` without rate-limit headers, so it reads as a transport
failure. A GitLab or Gitea token that is merely under-scoped often produces a
`404` instead, because those hosts hide resources you cannot see.

## `NotFound`

```
release or asset not found: {what}
```

Diagnostic code `rtb::vcs::not_found`.

Produced by a `404`, and by two backend-specific cases:

- GitLab `latest_release` when the newest release is an upcoming release;
  `what` is the literal string `latest release`.
- Bitbucket `latest_release` on a repository with no tags; `what` is
  `no tags available for latest release`.
- Direct `release_by_tag` when `pinned_version` is set and the requested tag
  does not equal it; `what` is the requested tag.

For a `404`, `what` is the status line rather than the thing you asked for
(`404 Not Found`). Do not parse it; keep your own record of what you requested.

A draft release requested without a token also lands here: the host returns a
`404` rather than admitting the release exists.

## `Unauthorized`

```
authentication failed for {host}
```

Diagnostic code `rtb::vcs::unauthorized`, with the help text "check the
credential registered for this release source".

Produced only by a `401`. The `host` field is the normalised host the provider
was built with, not the full request URL.

## `RateLimited`

```
rate limited by {host}; retry after {retry_after:?}
```

Diagnostic code `rtb::vcs::rate_limited`.

`retry_after: Option<Duration>` is filled in from the response headers:

1. `Retry-After`, if it parses as an integer number of seconds. A
   `Retry-After` in HTTP-date form is **not** understood and is skipped.
2. Otherwise `X-RateLimit-Reset`, read as Unix epoch seconds, and only if it is
   in the future. The duration is the difference from now.
3. Otherwise `None`.

`None` means "the host did not tell us", not "retry immediately". Apply your own
backoff.

## `Transport`

```
network transport error: {0}
```

Diagnostic code `rtb::vcs::transport`. Two quite different situations produce
it:

- The request never completed: DNS failure, TLS failure, connection refused,
  or the configured `timeout_seconds` elapsing. The payload is `reqwest`'s
  stringified error.
- The response arrived with a status the mapping does not recognise. The
  payload is `unexpected status {status} from {host}`.

Check for that `unexpected status` prefix if you need to tell a server error
apart from a network error.

## `MalformedResponse`

```
response body could not be parsed: {0}
```

Diagnostic code `rtb::vcs::malformed_response`. The host answered with `2xx` and
a body that did not deserialise into the expected shape. Most often a proxy or
captive portal returning an HTML error page with a `200`.

The Direct backend adds two of its own:

```text
direct version_url JSON missing `.version` string
direct version_url returned empty body
```

## `Unsupported`

```
operation is not supported by this provider
```

Diagnostic code `rtb::vcs::unsupported`, with help text pointing at
`latest_release` / `release_by_tag`.

Produced by exactly one call in the crate: `list_releases` on the Bitbucket
backend. Bitbucket Cloud has no list-releases endpoint, and synthesising one
would mean a downloads lookup per tag. There is no configuration that makes
Bitbucket `list_releases` work.

The variant carries no fields, so a tool that supports several backends should
treat it as "this capability is absent here" and degrade, not retry.

## `InvalidConfig`

```
provider configuration is invalid: {0}
```

Diagnostic code `rtb::vcs::invalid_config`. Raised by a factory before any
network call. The full set of messages and their triggers is in
[Release source configuration](02-release-source-configuration.md). The
categories are:

- A required parameter is empty or whitespace.
- A `host` or `version_url` uses `http://` where HTTPS is required.
- A `direct` template lacks `{version}`.
- A factory was handed a config for a different backend.
- `reqwest` could not build a client (`reqwest build failed: {e}`). This is a
  TLS-stack problem, not a config typo.

An `InvalidConfig` never becomes valid by retrying.

## `Io`

```
I/O error: {0}
```

Diagnostic code `rtb::vcs::io`. Wraps `Arc<std::io::Error>`. The `Arc` is what
keeps the whole enum `Clone` without discarding the error kind. There is a
`From<std::io::Error>` impl, so `?` on an I/O error inside a custom backend
produces this variant.

Cloning a `ProviderError::Io` shares the same `Arc`; both copies report the
same `ErrorKind`.

## Which variants can each backend produce?

| Variant | GitHub | GitLab | Gitea / Codeberg | Bitbucket | Direct |
|---|---|---|---|---|---|
| `NotFound` | yes | yes | yes | yes | yes |
| `Unauthorized` | yes | yes | yes | yes | yes |
| `RateLimited` | yes (429 and 403) | yes (429) | yes (429) | yes (429) | yes (429) |
| `Transport` | yes | yes | yes | yes | yes |
| `MalformedResponse` | yes | yes | yes | yes | yes |
| `Unsupported` | no | no | no | `list_releases` only | no |
| `InvalidConfig` | factory | factory | factory | factory | factory |
| `Io` | only from custom backends | | | | |