镜像站点 · 本页由第三方 GitHub 只读镜像提供,非 GitHub 官方站点,不接受任何登录或凭据输入。前往 github.com
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion docs/guides/payments.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,13 +29,20 @@ For a BOLT11 receive, `--qr` prints the invoice as a terminal QR code and `--cop

## Quotes and lines of credit

Quotes apply to USD lines of credit: a USD payment requires `--quote` with a quote for that line of credit.
A wallet on a USD line of credit needs a quote for every payment; without one, the API rejects the payment with `quote_required`. `voltage wallets get WALLET_ID` shows the wallet's `line_of_credit_id`, network, and balance currency.

To receive into a USD wallet, quote the USD amount to BTC, wait until `quotes get` shows a `quote` with no `error`, then receive the same USD amount with that quote:

```sh
voltage quotes create --profile prod --credit-line CREDIT_LINE_ID \
--network mutinynet --amount 10 --unit usd --to btc --execute
voltage quotes get QUOTE_ID --profile prod
voltage payments receive --profile prod --wallet WALLET_ID \
--currency usd --kind bolt11 --amount 10 --unit usd --quote QUOTE_ID --wait ready --execute
```

A quote is single-use and expires; a quoted receive's expiration is capped by the quote's remaining life.

On-chain and BIP21 payments and treasury movements are features Voltage enables per organization; the API rejects them with `feature_flag_disabled` until then, and the CLI says so.

## Prices
Expand Down
2 changes: 2 additions & 0 deletions docs/reference/exit-codes.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,5 @@
| 130 | Interrupted |

After exit code 4 for a payment, check its ID before you retry; see [payment IDs](../concepts/safety-model.md#payment-ids-and-the-request-journal). After exit code 5, keep watching with `voltage payments get PAYMENT_ID --profile prod --wait completed`.

Many errors for exit codes 2 and 3 carry a `hint:` (or `error.hint` with `--json`) that names the next command: the discovery command for a missing `--org`, `--env`, or `--wallet`, the saved credential and profile names for an unknown or ambiguous selection, `voltage logout --local --account NAME` and then `voltage login --account NAME` for an expired or revoked login, `voltage auth status` for a rejected account credential (HTTP 401), and `voltage context` for an account credential that cannot reach the selected scope (HTTP 403). A rejected checkout token points to `--token-file` or the variable that supplies it, never to the account credential. A payment rejected with `quote_required` points to `voltage quotes create`.
2 changes: 1 addition & 1 deletion docs/reference/output.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,6 @@ API fields stay inside `data`. With `--all`, JSON output collects the pages into

Diagnostics, prompts, and progress go to stderr. Interactive stderr shows immediate, cancellable status for network requests and waits; redirected stderr gets no spinner/control codes. `-q, --quiet` suppresses optional status and notices, never requested results, errors, warnings, or recovery IDs.

Human errors start with `error:` and may include a safe `hint:`. `--json` makes command-line parse errors machine-readable as well as runtime errors. A downstream consumer that closes stdout early is treated as successful pipeline completion.
Human errors start with `error:`, which leads with the API's reason when it rejects a request with a client error (HTTP 4xx), and may include a safe `hint:` and an indented `details:` block of `field value` lines. `--json` reports the complete API response in `error.detail`, and makes command-line parse errors machine-readable as well as runtime errors. A downstream consumer that closes stdout early is treated as successful pipeline completion.

Related: [scripting](../guides/scripting.md), [safety model](../concepts/safety-model.md#secrets-and-redaction).
184 changes: 155 additions & 29 deletions src/api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,29 @@ impl Authorization {
Self::None => Vec::new(),
}
}

/// The next step when the API rejects this credential (401) or what it can reach (403).
/// Checkout tokens are never the account credential, so they name their own source.
fn rejection_hint(&self, status: u16) -> Option<&'static str> {
match (self, status) {
(Self::Account(ApiCredential::ApiKey(_)), 401) => {
Some("Check the key with `voltage auth status`; the API does not accept it.")
}
(Self::Account(ApiCredential::OrganizationToken(_)), 401) => Some(
"Check the credential with `voltage auth status`. To sign in again, run `voltage logout --local --account NAME`, then `voltage login --account NAME`.",
),
(Self::Account(_), 403) => Some(
"Check the organization, environment, and credential in use with `voltage context`.",
),
(Self::CheckoutSession(_), 401 | 403) => Some(
"Check that --token-file or VOLTAGE_CHECKOUT_TOKEN holds this checkout session's token; checkout never uses the account credential.",
),
(Self::CheckoutStream(_), 401 | 403) => Some(
"Check that --token-file or VOLTAGE_STREAM_TOKEN holds this checkout stream's token; checkout never uses the account credential.",
),
_ => None,
}
}
}

/// A checkout credential from `--token-file`, else from `variable`.
Expand Down Expand Up @@ -222,19 +245,35 @@ impl Api {
} else {
ErrorKind::for_http_status(status)
};
Error::new(kind, format!("HTTP {status}: non-JSON response omitted"))
let error = Error::new(kind, format!("HTTP {status}: non-JSON response omitted"));
match self.authorization.rejection_hint(status) {
Some(hint) => error.with_hint(hint),
None => error,
}
})?
};
if !(200..300).contains(&status) {
return Err(Error::new(
let rejection = ApiErrorView::deserialize(&body).unwrap_or_default().error;
let mut error = Error::new(
ErrorKind::for_http_status(status),
rejection_message(status, &body),
)
.with_detail(ErrorDetail::Http {
http_status: status,
data: body,
})
.redacted(&self.secrets()));
rejection.message(status),
);
// A credential the API does not recognize is a rejected credential, whatever
// the status, so it gets the same next step as a 401.
let hint = if rejection.kind == ApiErrorType::MissingCredentials {
self.authorization.rejection_hint(401)
} else {
self.authorization.rejection_hint(status)
};
if let Some(hint) = hint.or_else(|| rejection.hint()) {
error = error.with_hint(hint);
}
return Err(error
.with_detail(ErrorDetail::Http {
http_status: status,
data: body,
})
.redacted(&self.secrets()));
}
Ok(Response {
status,
Expand Down Expand Up @@ -311,6 +350,9 @@ pub(crate) enum BodyFailure {
#[serde(rename_all = "snake_case")]
enum ApiErrorType {
FeatureFlagDisabled,
/// Staging and production answer a key they do not recognize this way, not with 401.
MissingCredentials,
QuoteRequired,
#[default]
#[serde(other)]
Other,
Expand All @@ -330,19 +372,36 @@ struct ApiErrorView {
error: ApiErrorBody,
}

/// The message for a rejected request; a disabled organization feature names the fix.
fn rejection_message(status: u16, body: &Value) -> String {
let rejection = ApiErrorView::deserialize(body).unwrap_or_default();
match rejection.error.kind {
ApiErrorType::FeatureFlagDisabled => format!(
"Voltage API returned HTTP {status}: {}. Ask Voltage to enable this feature for the organization.",
rejection
.error
.detail
.as_deref()
.unwrap_or("this feature is not enabled for the organization")
),
ApiErrorType::Other => format!("Voltage API returned HTTP {status}"),
impl ApiErrorBody {
/// The message for a rejected request: the API's own reason for a client error (4xx),
/// and for a disabled organization feature, the fix. A server error's text is not a
/// reason to show; it stays in the detail.
fn message(&self, status: u16) -> String {
let reason = self
.detail
.as_deref()
.filter(|_| (400..500).contains(&status));
match (&self.kind, reason) {
(ApiErrorType::FeatureFlagDisabled, reason) => format!(
"Voltage API returned HTTP {status}: {}. Ask Voltage to enable this feature for the organization.",
reason.unwrap_or("this feature is not enabled for the organization")
),
(_, Some(reason)) => format!("Voltage API returned HTTP {status}: {reason}"),
(_, None) => format!("Voltage API returned HTTP {status}"),
}
}

/// The next step an error type names. The CLI has not read the wallet, so a missing
/// quote describes the wallets that need one rather than this wallet.
fn hint(&self) -> Option<&'static str> {
match self.kind {
ApiErrorType::QuoteRequired => Some(
"A wallet on a USD line of credit needs a quote for each payment. Create one with `voltage quotes create`, wait until `voltage quotes get QUOTE_ID` shows it, then pass --quote QUOTE_ID. A wallet on a BTC line of credit needs no quote.",
),
ApiErrorType::FeatureFlagDisabled
| ApiErrorType::MissingCredentials
| ApiErrorType::Other => None,
}
}
}

Expand Down Expand Up @@ -1407,27 +1466,94 @@ mod tests {
}

#[test]
fn disabled_features_are_explained_and_other_rejections_stay_generic() {
let disabled = json!({"error": {
fn credential_rejections_name_the_next_step_for_their_own_credential() {
let account = Authorization::Account(ApiCredential::ApiKey(Secret::new("key".into())));
let hint = |authorization: &Authorization, status| authorization.rejection_hint(status);
assert!(hint(&account, 401).unwrap().contains("voltage auth status"));
// Signing in again cannot fix a rejected key, so only a login names `voltage login`.
assert!(!hint(&account, 401).unwrap().contains("voltage login"));
let login =
Authorization::Account(ApiCredential::OrganizationToken(Secret::new("t".into())));
let renew = hint(&login, 401).unwrap();
assert!(
renew.contains("voltage logout --local --account NAME")
&& renew.contains("voltage login --account NAME"),
"{renew}"
);
assert!(hint(&account, 403).unwrap().contains("voltage context"));
assert_eq!(hint(&account, 404), None);
assert_eq!(hint(&account, 500), None);
let session = Authorization::CheckoutSession(Secret::new("session".into()));
for status in [401, 403] {
let next = hint(&session, status).unwrap();
assert!(next.contains("VOLTAGE_CHECKOUT_TOKEN"), "{next}");
assert!(!next.contains("voltage auth status"), "{next}");
}
let stream = Authorization::CheckoutStream(Secret::new("stream".into()));
assert!(hint(&stream, 401).unwrap().contains("VOLTAGE_STREAM_TOKEN"));
assert_eq!(hint(&session, 404), None);
assert_eq!(hint(&Authorization::None, 401), None);
}

/// The parsed `error` object of a rejection body.
fn rejection(body: Value) -> ApiErrorBody {
ApiErrorView::deserialize(body).unwrap_or_default().error
}

#[test]
fn rejections_lead_with_the_api_reason_and_explain_known_types() {
let disabled = rejection(json!({"error": {
"type": "feature_flag_disabled", "code": "disabled",
"detail": "OnChain payments are not enabled for this organization",
"context": {"feature": "on_chain"}
}});
}}));
assert_eq!(
rejection_message(400, &disabled),
disabled.message(400),
"Voltage API returned HTTP 400: OnChain payments are not enabled for this organization. Ask Voltage to enable this feature for the organization."
);
let other = json!({"error": {"type": "invalid_amount", "detail": "too small"}});
let other = rejection(json!({"error": {"type": "invalid_amount", "detail": "too small"}}));
assert_eq!(
rejection_message(400, &other),
other.message(400),
"Voltage API returned HTTP 400: too small"
);
assert_eq!(other.hint(), None);
let quote = rejection(json!({"error": {
"type": "quote_required", "code": "required",
"detail": "Quote is required for payment",
"context": {"field": "quote_id", "required": true}
}}));
assert_eq!(
quote.message(400),
"Voltage API returned HTTP 400: Quote is required for payment"
);
let hint = quote.hint().unwrap();
assert!(hint.starts_with("A wallet on a USD line of credit needs a quote"));
assert!(hint.contains("voltage quotes create"));
assert!(hint.contains("BTC line of credit needs no quote"));
assert_eq!(
rejection(json!({"error": {"type": "invalid_amount"}})).message(400),
"Voltage API returned HTTP 400"
);
assert_eq!(
rejection_message(502, &Value::Null),
rejection(Value::Null).message(502),
"Voltage API returned HTTP 502"
);
}

#[test]
fn server_errors_keep_their_text_out_of_the_message() {
let internal =
rejection(json!({"error": {"type": "internal", "detail": "db pool exhausted"}}));
assert_eq!(internal.message(500), "Voltage API returned HTTP 500");
let disabled = rejection(json!({"error": {
"type": "feature_flag_disabled", "detail": "internal text"
}}));
assert_eq!(
disabled.message(503),
"Voltage API returned HTTP 503: this feature is not enabled for the organization. Ask Voltage to enable this feature for the organization."
);
}

#[test]
fn retry_hints_prefer_milliseconds_then_seconds_then_dates() {
let mut headers = HeaderMap::new();
Expand Down
Loading
Loading