# Errors

Single error enum across the crate.

```rust
use akira_billing::Error;

#[derive(Debug, thiserror::Error)]
pub enum Error {
    #[error("transport error: {0}")]
    Transport(#[from] reqwest::Error),

    #[error("billing api {status}: {code}")]
    Api { status: u16, code: String },

    #[error("decode response: {0}")]
    Decode(#[from] serde_json::Error),
}
```

## Variants

| Variant | When |
|---------|------|
| `Transport(reqwest::Error)` | DNS / TCP / TLS / timeout failures. Bubbled up from `reqwest`. |
| `Api { status, code }` | Billing server returned a 4xx/5xx. `code` is parsed from `{"error":"..."}` or `{"message":"..."}`. |
| `Decode(serde_json::Error)` | Server responded 2xx with a body the client could not deserialize. Should not happen in practice; treat as a bug. |

## Branching pattern

```rust
use akira_billing::Error;

match client.license_activate(payload).await {
    Ok(res) => { /* … */ }
    Err(Error::Api { status: 402, code }) if code == "no_active_plan" => {
        redirect_to_upgrade();
    }
    Err(Error::Api { status: 409, code }) if code == "device_limit_reached" => {
        show_device_manager();
    }
    Err(Error::Transport(err)) if err.is_timeout() => {
        retry_with_backoff();
    }
    Err(err) => return Err(err.into()),
}
```

## Catalogue (non-exhaustive)

Stable codes the billing API emits — safe to branch on.

### Authentication

| Code | Status |
|------|--------|
| `missing_signature_headers` | 401 |
| `unknown_product` | 401 |
| `timestamp_skew` | 401 |
| `bad_signature` | 401 |
| `nonce_replay` | 401 |
| `unauthorized` | 401 |
| `forbidden` | 403 |

### Customer + licensing

| Code | Status |
|------|--------|
| `customer_not_found` | 404 |
| `no_active_plan` | 402 |
| `device_limit_reached` | 409 |
| `license_invalid` | 409 |
| `license_revoked` | 410 |
| `fingerprint_mismatch` | 409 |

### Usage

| Code | Status |
|------|--------|
| `feature_disabled` | 403 |
| `limit_reached` | 429 |
| `usage_window_closed` | 409 |

### OAuth

| Code | Status |
|------|--------|
| `oauth_provider_unsupported` | 400 |
| `oauth_state_mismatch` | 400 |
| `oauth_code_invalid` | 400 |

## Retry guidance

- `Transport(_)` with `is_timeout()` or `is_connect()` → safe to retry with exponential backoff. Build a fresh request — the timestamp and nonce should not be reused.
- `Api { status: 5xx, .. }` → retry; the signature is still valid for the timestamp window.
- `Api { code: "timestamp_skew", .. }` → resync the system clock; rebuild the request.
- `Api { code: "nonce_replay", .. }` → almost always a logic bug. Investigate.
- `Api { code: "limit_reached", .. }` → surface to the user; do not auto-retry.

The crate does not retry on its own — wrap with `tower::retry`, `backoff`, or a custom layer.

## Distinguishing classes

```rust
fn classify(err: &Error) -> &'static str {
    match err {
        Error::Transport(e) if e.is_timeout()  => "timeout",
        Error::Transport(e) if e.is_connect()  => "connect",
        Error::Transport(_)                    => "transport",
        Error::Api { status: 401, .. }         => "auth",
        Error::Api { status: 402, .. }         => "billing",
        Error::Api { status: 429, .. }         => "rate_limit",
        Error::Api { status: s, .. } if *s >= 500 => "server",
        Error::Api { .. }                      => "client",
        Error::Decode(_)                       => "decode",
    }
}
```

Useful for tagging tracing spans or logs.

---

Navigation: [← Signature](11-signature.md) · **Errors** · [OAuth →](24-oauth.md)
