Skip to main content

r402_http/buyer/
retry.rs

1//! Paid retry is HTTP 402 only. 412 and 502 are returned as-is.
2
3use std::fmt::{self, Debug, Formatter};
4
5use http::{Extensions, HeaderMap, StatusCode};
6use r402_client::{
7    ClientExtension, ClientHooks, CreatedPayment, FirstMatch, PaymentClient, PaymentPolicy,
8    PaymentResponseContext, PaymentSelector, SchemeClient, SpendControls,
9};
10use r402_protocol::ClientError;
11use r402_protocol::payment::PaymentRequired;
12use reqwest::{Client, Request, Response};
13use reqwest_middleware as rqm;
14
15use super::signature::{
16    clone_with_payment, parse_payment_required, payment_required_from_headers,
17    payment_signature_headers, settle_response_from_headers,
18};
19
20/// HTTP middleware over a core [`PaymentClient`].
21///
22/// On `402 Payment Required`, signs a payment and retries once (or twice if
23/// `on_payment_response` signals recovery). Other statuses, including 412 and
24/// 502, are never retried.
25pub struct X402Client<TSelector = FirstMatch> {
26    inner: PaymentClient<TSelector>,
27}
28
29impl X402Client<FirstMatch> {
30    /// Creates a new client with [`FirstMatch`] selection.
31    #[must_use]
32    pub fn new() -> Self {
33        Self::default()
34    }
35}
36
37impl Default for X402Client<FirstMatch> {
38    fn default() -> Self {
39        Self {
40            inner: PaymentClient::new(),
41        }
42    }
43}
44
45impl<TSelector> Debug for X402Client<TSelector> {
46    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
47        f.debug_struct("X402Client")
48            .field("inner", &self.inner)
49            .finish()
50    }
51}
52
53impl<TSelector> X402Client<TSelector> {
54    /// Builds from an existing core payment client.
55    #[must_use]
56    pub const fn from_payment_client(inner: PaymentClient<TSelector>) -> Self {
57        Self { inner }
58    }
59
60    /// Returns a shared reference to the core payment client.
61    #[must_use]
62    pub const fn payment_client(&self) -> &PaymentClient<TSelector> {
63        &self.inner
64    }
65
66    /// Registers a scheme client.
67    #[must_use]
68    pub fn register(mut self, scheme: impl SchemeClient + 'static) -> Self {
69        self.inner = self.inner.register(scheme);
70        self
71    }
72
73    /// Sets a custom payment selector.
74    #[must_use]
75    pub fn with_selector<P: PaymentSelector>(self, selector: P) -> X402Client<P> {
76        X402Client {
77            inner: self.inner.with_selector(selector),
78        }
79    }
80
81    /// Adds a payment policy.
82    #[must_use]
83    pub fn with_policy(mut self, policy: impl PaymentPolicy + 'static) -> Self {
84        self.inner = self.inner.with_policy(policy);
85        self
86    }
87
88    /// Adds a client lifecycle hook (including `on_payment_response`).
89    #[must_use]
90    pub fn with_hook(mut self, hook: impl ClientHooks + 'static) -> Self {
91        self.inner = self.inner.with_hook(hook);
92        self
93    }
94
95    /// Adds a client extension (HTTP 402 header hook and payload enrich).
96    #[must_use]
97    pub fn with_extension(mut self, extension: impl ClientExtension + 'static) -> Self {
98        self.inner = self.inner.with_extension(extension);
99        self
100    }
101
102    /// Enables spend controls with the given configuration.
103    #[must_use]
104    pub fn with_spend_controls(mut self, controls: SpendControls) -> Self {
105        self.inner = self.inner.with_spend_controls(controls);
106        self
107    }
108
109    /// Disables all spend controls (any asset, no caps).
110    #[must_use]
111    pub fn disable_spend_controls(mut self) -> Self {
112        self.inner = self.inner.disable_spend_controls();
113        self
114    }
115}
116
117impl<TSelector> X402Client<TSelector>
118where
119    TSelector: PaymentSelector,
120{
121    /// Creates `Payment-Signature` plus advertised extension headers from a 402.
122    ///
123    /// `SIGN-IN-WITH-X` is included when a registered extension's `key` is in
124    /// `PaymentRequired.extensions`.
125    ///
126    /// # Errors
127    ///
128    /// Parse, selection, signing, or before-hook abort.
129    pub async fn make_payment_headers(&self, res: Response) -> Result<HeaderMap, ClientError> {
130        let request_url = res.url().clone();
131        let payment_required = parse_payment_required(res)
132            .await
133            .ok_or_else(|| ClientError::Parse("Invalid 402 response".to_owned()))?;
134        let created = self.inner.create_payment(&payment_required).await?;
135        let mut headers = payment_signature_headers(&created)?;
136        headers.extend(
137            self.inner
138                .extension_headers(&payment_required, request_url.as_str())
139                .await,
140        );
141        Ok(headers)
142    }
143
144    /// Creates a payment for an already-parsed challenge.
145    ///
146    /// # Errors
147    ///
148    /// Selection, signing, or before-hook abort.
149    pub async fn create_payment(
150        &self,
151        payment_required: &PaymentRequired,
152    ) -> Result<CreatedPayment, ClientError> {
153        self.inner.create_payment(payment_required).await
154    }
155}
156
157impl<S> X402Client<S>
158where
159    Self: rqm::Middleware,
160{
161    /// Wraps a reqwest [`Client`] with x402 payment middleware.
162    #[must_use]
163    pub fn wrap(self, client: Client) -> rqm::ClientWithMiddleware {
164        rqm::ClientBuilder::new(client).with(self).build()
165    }
166
167    /// Wraps a reqwest [`Client`] and returns the middleware builder.
168    #[must_use]
169    pub fn wrap_builder(self, client: Client) -> rqm::ClientBuilder {
170        rqm::ClientBuilder::new(client).with(self)
171    }
172}
173
174/// Extension trait for adding x402 payment handling to a reqwest [`Client`].
175pub trait WithPayments {
176    /// Adds x402 payment middleware, returning a ready-to-use client.
177    #[must_use]
178    fn with_payments<S>(self, x402: X402Client<S>) -> rqm::ClientWithMiddleware
179    where
180        X402Client<S>: rqm::Middleware;
181}
182
183impl WithPayments for Client {
184    fn with_payments<S>(self, x402: X402Client<S>) -> rqm::ClientWithMiddleware
185    where
186        X402Client<S>: rqm::Middleware,
187    {
188        x402.wrap(self)
189    }
190}
191
192/// Paid auto-retry is 402 only. Permit2 412 and facilitator transport 502
193/// must not be retried.
194fn should_retry(status: StatusCode) -> bool {
195    status == StatusCode::PAYMENT_REQUIRED
196}
197
198#[async_trait::async_trait]
199impl<TSelector> rqm::Middleware for X402Client<TSelector>
200where
201    TSelector: PaymentSelector + Send + Sync + 'static,
202{
203    async fn handle(
204        &self,
205        req: Request,
206        extensions: &mut Extensions,
207        next: rqm::Next<'_>,
208    ) -> rqm::Result<Response> {
209        let retry_template = req.try_clone();
210        let res = next.clone().run(req, extensions).await?;
211        if !should_retry(res.status()) {
212            return Ok(res);
213        }
214
215        let Some(template) = retry_template else {
216            return Ok(res);
217        };
218
219        let request_url = res.url().clone();
220        let payment_required = parse_payment_required(res)
221            .await
222            .ok_or_else(|| middleware_err(ClientError::Parse("Invalid 402 response".into())))?;
223        let created = self
224            .inner
225            .create_payment(&payment_required)
226            .await
227            .map_err(middleware_err)?;
228        let paid = clone_with_payment_and_extensions(
229            &template,
230            &created,
231            &payment_required,
232            request_url.as_str(),
233            &self.inner,
234        )
235        .await
236        .map_err(middleware_err)?;
237        let response = next.clone().run(paid, extensions).await?;
238
239        let Some(recovered) = self
240            .dispatch_and_maybe_recover(&payment_required, &created, &response)
241            .await
242            .map_err(middleware_err)?
243        else {
244            return Ok(response);
245        };
246
247        let second = clone_with_payment_and_extensions(
248            &template,
249            &recovered,
250            &recovered.payment_required,
251            response.url().as_str(),
252            &self.inner,
253        )
254        .await
255        .map_err(middleware_err)?;
256        let second_response = next.run(second, extensions).await?;
257        let ctx = build_response_context(&payment_required, &recovered, &second_response);
258        if ctx.settle_response.is_some() || ctx.corrective_payment_required.is_some() {
259            self.inner.handle_payment_response(&ctx).await;
260        }
261        Ok(second_response)
262    }
263}
264
265impl<TSelector: PaymentSelector> X402Client<TSelector> {
266    /// Returns `Some(new_created)` when hooks signal recovery and a fresh
267    /// payment can be built from the corrective challenge (or original).
268    async fn dispatch_and_maybe_recover(
269        &self,
270        original: &PaymentRequired,
271        created: &CreatedPayment,
272        response: &Response,
273    ) -> Result<Option<CreatedPayment>, ClientError> {
274        let ctx = build_response_context(original, created, response);
275        if ctx.settle_response.is_none() && ctx.corrective_payment_required.is_none() {
276            return Ok(None);
277        }
278
279        let result = self.inner.handle_payment_response(&ctx).await;
280        if !result.recovered {
281            return Ok(None);
282        }
283
284        let challenge = ctx.corrective_payment_required.as_ref().unwrap_or(original);
285        let fresh = self.inner.create_payment(challenge).await?;
286        Ok(Some(fresh))
287    }
288}
289
290async fn clone_with_payment_and_extensions<TSelector: PaymentSelector>(
291    template: &Request,
292    created: &CreatedPayment,
293    payment_required: &PaymentRequired,
294    request_url: &str,
295    client: &PaymentClient<TSelector>,
296) -> Result<Request, ClientError> {
297    let mut paid = clone_with_payment(template, created)?;
298    paid.headers_mut().extend(
299        client
300            .extension_headers(payment_required, request_url)
301            .await,
302    );
303    Ok(paid)
304}
305
306fn build_response_context(
307    original: &PaymentRequired,
308    created: &CreatedPayment,
309    response: &Response,
310) -> PaymentResponseContext {
311    let settle_response = settle_response_from_headers(response.headers());
312    let corrective_payment_required =
313        if settle_response.is_none() && should_retry(response.status()) {
314            payment_required_from_headers(response.headers())
315        } else {
316            None
317        };
318
319    let mut ctx = PaymentResponseContext::new(original.clone(), created.signed_payload.clone());
320    if let Some(settle) = settle_response {
321        ctx = ctx.with_settle_response(settle);
322    }
323    if let Some(required) = corrective_payment_required {
324        ctx = ctx.with_corrective_payment_required(required);
325    }
326    ctx
327}
328
329fn middleware_err(err: ClientError) -> rqm::Error {
330    rqm::Error::middleware(err)
331}
332
333#[cfg(test)]
334mod tests {
335    use super::*;
336
337    #[test]
338    fn retries_only_status_402() {
339        assert!(
340            should_retry(StatusCode::PAYMENT_REQUIRED),
341            "402 is the only auto-retry status"
342        );
343        assert!(
344            !should_retry(StatusCode::PRECONDITION_FAILED),
345            "412 Permit2 must not retry"
346        );
347        assert!(
348            !should_retry(StatusCode::BAD_GATEWAY),
349            "502 transport must not retry"
350        );
351        assert!(
352            !should_retry(StatusCode::TOO_MANY_REQUESTS),
353            "429 is facilitator /supported, not buyer retry"
354        );
355        assert!(!should_retry(StatusCode::OK), "200 must not retry");
356    }
357}