Skip to content

Commit 133756c

Browse files
committed
Improve Paygate builder pattern and optimize 402 automatic retry handling
Change Paygate fields from pub to pub(crate), add builder() constructor and accepts()/resource() accessor methods Introduce PaygateBuilder struct with chainable methods: accept(), accepts(), resource(), settle_before_execution() In r402-http/src/server/layer.rs, replace direct struct initialization with the new builder API Modify 402 handling logic in r402-http/src/client/middleware.rs: when the request body is not clonable, fail immediately instead of attempting retry
1 parent 3c691bf commit 133756c

File tree

3 files changed

+116
-24
lines changed

3 files changed

+116
-24
lines changed

r402-http/src/client/middleware.rs

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -281,6 +281,10 @@ where
281281
/// 1. Extracts payment requirements from the response
282282
/// 2. Signs a payment using registered scheme clients
283283
/// 3. Retries the request with the payment header
284+
///
285+
/// If the request body is not cloneable (e.g. streaming), the middleware
286+
/// cannot auto-retry after a 402. In that case the original 402 response
287+
/// is returned as-is so the caller can handle it manually.
284288
#[cfg_attr(
285289
feature = "telemetry",
286290
instrument(name = "x402.reqwest.handle", skip_all, err)
@@ -303,15 +307,19 @@ where
303307
#[cfg(feature = "telemetry")]
304308
info!(url = ?res.url(), "Received 402 Payment Required, processing payment");
305309

310+
// If the original request is not cloneable (streaming body), we cannot
311+
// auto-retry. Return the 402 response for manual handling by the caller.
312+
let Some(mut retry) = retry_req else {
313+
#[cfg(feature = "telemetry")]
314+
tracing::warn!("Cannot auto-retry 402: request body not cloneable, returning raw 402");
315+
return Ok(res);
316+
};
317+
306318
let headers = self
307319
.make_payment_headers(res)
308320
.await
309321
.map_err(|e| rqm::Error::Middleware(e.into()))?;
310322

311-
// Retry with payment
312-
let mut retry = retry_req.ok_or(rqm::Error::Middleware(
313-
ClientError::RequestNotCloneable.into(),
314-
))?;
315323
retry.headers_mut().extend(headers);
316324

317325
#[cfg(feature = "telemetry")]

r402-http/src/server/layer.rs

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -377,12 +377,11 @@ where
377377
let resource = resource_builder.as_resource_info(base_url.as_deref(), &req);
378378

379379
let gate = {
380-
let mut gate = Paygate {
381-
facilitator,
382-
settle_before_execution,
383-
accepts: Arc::new(accepts),
384-
resource,
385-
};
380+
let mut gate = Paygate::builder(facilitator)
381+
.accepts(accepts)
382+
.resource(resource)
383+
.settle_before_execution(settle_before_execution)
384+
.build();
386385
gate.enrich_accepts().await;
387386
gate
388387
};

r402-http/src/server/paygate.rs

Lines changed: 99 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -84,19 +84,106 @@ impl ResourceInfoBuilder {
8484
/// Handles the full payment lifecycle: header extraction, verification,
8585
/// settlement, and 402 response generation using the V2 wire format.
8686
///
87+
/// Construct via [`PaygateBuilder`] (obtained from [`Paygate::builder`]).
88+
///
8789
/// To add lifecycle hooks (before/after verify and settle), wrap your
8890
/// facilitator with [`HookedFacilitator`](r402::hooks::HookedFacilitator)
8991
/// before passing it to the payment gate.
9092
#[allow(missing_debug_implementations)]
9193
pub struct Paygate<TFacilitator> {
92-
/// The facilitator for verifying and settling payments
93-
pub facilitator: TFacilitator,
94-
/// Whether to settle before or after request execution
95-
pub settle_before_execution: bool,
96-
/// Accepted V2 payment requirements
97-
pub accepts: Arc<Vec<v2::PriceTag>>,
98-
/// Resource information for the protected endpoint
99-
pub resource: v2::ResourceInfo,
94+
pub(crate) facilitator: TFacilitator,
95+
pub(crate) settle_before_execution: bool,
96+
pub(crate) accepts: Arc<Vec<v2::PriceTag>>,
97+
pub(crate) resource: v2::ResourceInfo,
98+
}
99+
100+
/// Builder for constructing a [`Paygate`] with validated configuration.
101+
///
102+
/// # Example
103+
///
104+
/// ```ignore
105+
/// let gate = Paygate::builder(facilitator)
106+
/// .accept(price_tag)
107+
/// .resource(resource_info)
108+
/// .settle_before_execution(true)
109+
/// .build();
110+
/// ```
111+
#[allow(missing_debug_implementations)]
112+
pub struct PaygateBuilder<TFacilitator> {
113+
facilitator: TFacilitator,
114+
settle_before_execution: bool,
115+
accepts: Vec<v2::PriceTag>,
116+
resource: Option<v2::ResourceInfo>,
117+
}
118+
119+
impl<TFacilitator> Paygate<TFacilitator> {
120+
/// Returns a new builder seeded with the given facilitator.
121+
pub const fn builder(facilitator: TFacilitator) -> PaygateBuilder<TFacilitator> {
122+
PaygateBuilder {
123+
facilitator,
124+
settle_before_execution: false,
125+
accepts: Vec::new(),
126+
resource: None,
127+
}
128+
}
129+
130+
/// Returns a reference to the accepted price tags.
131+
pub fn accepts(&self) -> &[v2::PriceTag] {
132+
&self.accepts
133+
}
134+
135+
/// Returns a reference to the resource information.
136+
pub const fn resource(&self) -> &v2::ResourceInfo {
137+
&self.resource
138+
}
139+
}
140+
141+
impl<TFacilitator> PaygateBuilder<TFacilitator> {
142+
/// Adds a single accepted payment option.
143+
#[must_use]
144+
pub fn accept(mut self, price_tag: v2::PriceTag) -> Self {
145+
self.accepts.push(price_tag);
146+
self
147+
}
148+
149+
/// Adds multiple accepted payment options.
150+
#[must_use]
151+
pub fn accepts(mut self, price_tags: impl IntoIterator<Item = v2::PriceTag>) -> Self {
152+
self.accepts.extend(price_tags);
153+
self
154+
}
155+
156+
/// Sets the resource metadata returned in 402 responses.
157+
#[must_use]
158+
pub fn resource(mut self, resource: v2::ResourceInfo) -> Self {
159+
self.resource = Some(resource);
160+
self
161+
}
162+
163+
/// Enables or disables settlement before request execution.
164+
///
165+
/// Default is `false` (settle after execution).
166+
#[must_use]
167+
pub const fn settle_before_execution(mut self, enabled: bool) -> Self {
168+
self.settle_before_execution = enabled;
169+
self
170+
}
171+
172+
/// Consumes the builder and produces a configured [`Paygate`].
173+
///
174+
/// Uses empty resource info if none was provided.
175+
pub fn build(self) -> Paygate<TFacilitator> {
176+
Paygate {
177+
facilitator: self.facilitator,
178+
settle_before_execution: self.settle_before_execution,
179+
accepts: Arc::new(self.accepts),
180+
resource: self.resource.unwrap_or_else(|| v2::ResourceInfo {
181+
description: String::new(),
182+
mime_type: "application/json".to_owned(),
183+
url: String::new(),
184+
}),
185+
}
186+
}
100187
}
101188

102189
/// The V2 payment header name.
@@ -379,15 +466,13 @@ fn error_into_response(
379466
.expect("Fail to construct response")
380467
}
381468
PaygateError::Settlement(ref err) => {
469+
#[cfg(feature = "telemetry")]
470+
tracing::error!(details = %err, "Settlement failed");
382471
let body = Body::from(
383-
json!({
384-
"error": "Settlement failed",
385-
"details": err
386-
})
387-
.to_string(),
472+
json!({ "error": "Settlement failed" }).to_string(),
388473
);
389474
Response::builder()
390-
.status(StatusCode::PAYMENT_REQUIRED)
475+
.status(StatusCode::INTERNAL_SERVER_ERROR)
391476
.header("Content-Type", "application/json")
392477
.body(body)
393478
.expect("Fail to construct response")

0 commit comments

Comments
 (0)