agent402-client 0.8.21 → 0.8.22

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -166,6 +166,14 @@ What the server does with it depends on how the call was paid:
166
166
  route with the same body, from its first answer inside the replay window,
167
167
  with no second settlement, and marks it `X-Replayed-Answer: 1` (identity-bound
168
168
  routes, routes with a lasting effect and streamed calls excepted).
169
+ - **A connection reset before any answer:** the SDK's own requests (proof-of-work,
170
+ credits, discovery) are resent once when the connection failed before the
171
+ server answered, for example on a kept-alive socket the edge had already
172
+ closed (`retryResets: false` turns this off). Your `payFetch` is not wrapped,
173
+ because a retry over a payment wrapper would sign a fresh authorization. To get
174
+ the same resend on the wallet path, put it under the wrapper:
175
+ `wrapFetchWithPayment(resetRetryFetch(distrustFetch()), client)`. The resend
176
+ carries the same signed header, and one authorization settles at most once.
169
177
  - **Circle Gateway batched option:** a payment the server has not had confirmed
170
178
  by Gateway yet is answered `402` with reason `settlement-pending`; the SDK
171
179
  resends the same signed header after the server's `Retry-After` (never a new
package/index.js CHANGED
@@ -23,6 +23,8 @@ import { quoteOf, guardSigning, networkIdsOf, acceptUsd } from "./known-assets.j
23
23
  export { KNOWN_ASSETS, NETWORK_NAMES, knownAsset, acceptUsd, networkIdsOf } from "./known-assets.js";
24
24
  import { distrustFetch } from "./tls-distrust.js";
25
25
  export { distrustFetch, distrustDispatcher, distrustCheckServerIdentity, DISTRUSTED_SPKI_SHA256 } from "./tls-distrust.js";
26
+ export { resetRetryFetch, isConnectionReset } from "./reset-retry.js";
27
+ import { resetRetryFetch } from "./reset-retry.js";
26
28
  import { awaitJob, shouldRequestAsync, AsyncJobPendingError, DEFAULT_ASYNC_TIMEOUT_MS } from "./async-job.js";
27
29
  export { AsyncJobPendingError } from "./async-job.js";
28
30
 
@@ -30,7 +32,7 @@ export { AsyncJobPendingError } from "./async-job.js";
30
32
  // `User-Agent: agent402-client/<version>` - a standard header, no extra
31
33
  // network calls - so a seller can attribute traffic (and settled payments)
32
34
  // to this SDK. Product token only; nothing about the caller rides along.
33
- const VERSION = "0.8.21";
35
+ const VERSION = "0.8.22";
34
36
  const USER_AGENT = `agent402-client/${VERSION}`;
35
37
  // A package built on this SDK (an adapter) names itself first: its own
36
38
  // product token, then this one. Anything that is not one well-formed token
@@ -129,6 +131,8 @@ export class Agent402 {
129
131
  * sent before agent402-client's in the User-Agent
130
132
  * @param {boolean} [opts.tlsDistrust=true] send the default fetch through distrustFetch (refuses a TLS
131
133
  * chain through a distrusted intermediate CA); false sends it as is
134
+ * @param {boolean} [opts.retryResets=true] resend the SDK's own request once when its connection failed
135
+ * before any answer (see resetRetryFetch); false sends each once
132
136
  * @param {number} [opts.maxPerCallUsd] hard ceiling on a single paid call (USD); over → SpendingLimitError before paying
133
137
  * @param {number} [opts.dailyLimitUsd] hard ceiling on rolling-24h paid spend (USD)
134
138
  * @param {number} [opts.maxPerHostUsd] hard ceiling on rolling-24h paid spend to one seller host (USD)
@@ -163,7 +167,7 @@ export class Agent402 {
163
167
  constructor({ baseUrl = "https://agent402.tools", fetch: payFetch, cache = true, fetchImpl = undefined, tlsDistrust = true,
164
168
  maxPerCallUsd = null, dailyLimitUsd = null, maxPerHostUsd = null, creditsKey = null, x402Client = null, payNetworks = null,
165
169
  maxResponseBytes = DEFAULT_MAX_RESPONSE_BYTES, outputValidator = null,
166
- respondAsync = "auto", asyncTimeoutMs = DEFAULT_ASYNC_TIMEOUT_MS, onProgress = null, gateway = null, userAgent = null } = {}) {
170
+ respondAsync = "auto", asyncTimeoutMs = DEFAULT_ASYNC_TIMEOUT_MS, onProgress = null, gateway = null, userAgent = null, retryResets = true } = {}) {
167
171
  this.userAgent = userAgentOf(userAgent);
168
172
  this.respondAsync = respondAsync;
169
173
  this.gateway = normalizeGateway(gateway);
@@ -175,6 +179,10 @@ export class Agent402 {
175
179
  if (typeof fetchImpl === "function" && tlsDistrust !== false) fetchImpl = distrustFetch(fetchImpl);
176
180
  }
177
181
  if (typeof fetchImpl !== "function") throw new Error("No fetch available - pass { fetchImpl } on Node < 18");
182
+ // The SDK's own requests resend once when the connection failed before any
183
+ // answer (a closed keep-alive socket). A caller's payFetch is not wrapped:
184
+ // retrying over a payment wrapper would sign a fresh authorization.
185
+ if (retryResets !== false) fetchImpl = resetRetryFetch(fetchImpl);
178
186
  this.baseUrl = String(baseUrl).replace(/\/$/, "");
179
187
  this.payFetch = payFetch || null;
180
188
  // Wrap the plain fetch so every request identifies the SDK (see USER_AGENT
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent402-client",
3
- "version": "0.8.21",
3
+ "version": "0.8.22",
4
4
  "description": "Agent402 buyer SDK: resolve a task to a tool across 500+ pay-per-call endpoints and call it with payment handled - free via proof-of-work, or over x402 or MPP (a stock mppx fetch works) with a wallet you keep; the buy side of Agentic Finance. Caching, idempotent retries (Idempotency-Key), non-custodial, zero dependencies.",
5
5
  "type": "module",
6
6
  "main": "index.js",
@@ -12,6 +12,7 @@
12
12
  "async-job.js",
13
13
  "known-assets.js",
14
14
  "tls-distrust.js",
15
+ "reset-retry.js",
15
16
  "README.md"
16
17
  ],
17
18
  "engines": {
package/reset-retry.js ADDED
@@ -0,0 +1,47 @@
1
+ // resetRetryFetch(baseFetch) returns a fetch that resends a request ONCE when
2
+ // its connection failed before any response arrived: a kept-alive socket the
3
+ // server's edge already closed answers the next write with ECONNRESET (or
4
+ // undici's "other side closed"), and the request never reached the server.
5
+ //
6
+ // Only a transport error is retried, never an HTTP answer, and only for a
7
+ // body that can be sent again (none, a string, bytes, URLSearchParams, a
8
+ // Blob); a streamed body is not. The resend carries the same headers, so a
9
+ // signed payment is resent as signed: an EIP-3009 authorization's nonce can
10
+ // settle only once, and the server answers a re-sent signed payment from its
11
+ // first answer, so the resend can never pay twice. Wrap it UNDER a payment
12
+ // wrapper (wrapFetchWithPayment(resetRetryFetch(fetch), client)), never over
13
+ // one: over a wrapper, a retry would sign a fresh authorization.
14
+ const RESET_CODES = new Set(["ECONNRESET", "EPIPE", "UND_ERR_SOCKET", "UND_ERR_CLOSED", "ECONNABORTED"]);
15
+
16
+ /** Did this fetch error fail on the connection before any response? */
17
+ export function isConnectionReset(err) {
18
+ let e = err;
19
+ for (let i = 0; i < 5 && e; i++) {
20
+ if (RESET_CODES.has(e.code)) return true;
21
+ if (/other side closed|socket hang up/i.test(String(e.message || ""))) return true;
22
+ e = e.cause;
23
+ }
24
+ return false;
25
+ }
26
+
27
+ function replayableBody(body) {
28
+ if (body === undefined || body === null || typeof body === "string") return true;
29
+ if (body instanceof ArrayBuffer || ArrayBuffer.isView(body)) return true;
30
+ if (typeof URLSearchParams !== "undefined" && body instanceof URLSearchParams) return true;
31
+ if (typeof Blob !== "undefined" && body instanceof Blob) return true;
32
+ return false;
33
+ }
34
+
35
+ export function resetRetryFetch(baseFetch = globalThis.fetch, { pauseMs = 300 } = {}) {
36
+ if (typeof baseFetch !== "function") throw new Error("resetRetryFetch needs a fetch function");
37
+ return async function fetchWithResetRetry(input, init) {
38
+ try {
39
+ return await baseFetch(input, init);
40
+ } catch (e) {
41
+ const isRequest = typeof Request !== "undefined" && input instanceof Request;
42
+ if (!isConnectionReset(e) || init?.signal?.aborted || isRequest || !replayableBody(init?.body)) throw e;
43
+ await new Promise((r) => setTimeout(r, pauseMs));
44
+ return baseFetch(input, init);
45
+ }
46
+ };
47
+ }