@axorum/client 0.0.0-stage → 1.1.0
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/LICENSE +202 -0
- package/README.md +537 -2
- package/dist/agent-uri.d.ts +33 -0
- package/dist/agent-uri.d.ts.map +1 -0
- package/dist/agent-uri.js +231 -0
- package/dist/agent-uri.js.map +1 -0
- package/dist/authorize.d.ts +671 -0
- package/dist/authorize.d.ts.map +1 -0
- package/dist/authorize.js +947 -0
- package/dist/authorize.js.map +1 -0
- package/dist/capabilities.d.ts +30 -0
- package/dist/capabilities.d.ts.map +1 -0
- package/dist/capabilities.js +40 -0
- package/dist/capabilities.js.map +1 -0
- package/dist/chart.d.ts +74 -0
- package/dist/chart.d.ts.map +1 -0
- package/dist/chart.js +107 -0
- package/dist/chart.js.map +1 -0
- package/dist/client.d.ts +645 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +1303 -0
- package/dist/client.js.map +1 -0
- package/dist/errors.d.ts +646 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +928 -0
- package/dist/errors.js.map +1 -0
- package/dist/failover.d.ts +197 -0
- package/dist/failover.d.ts.map +1 -0
- package/dist/failover.js +289 -0
- package/dist/failover.js.map +1 -0
- package/dist/freshness.d.ts +65 -0
- package/dist/freshness.d.ts.map +1 -0
- package/dist/freshness.js +148 -0
- package/dist/freshness.js.map +1 -0
- package/dist/generated/agent-plane.d.ts +1865 -0
- package/dist/generated/agent-plane.d.ts.map +1 -0
- package/dist/generated/agent-plane.js +19 -0
- package/dist/generated/agent-plane.js.map +1 -0
- package/dist/idempotency.d.ts +160 -0
- package/dist/idempotency.d.ts.map +1 -0
- package/dist/idempotency.js +208 -0
- package/dist/idempotency.js.map +1 -0
- package/dist/index.d.ts +90 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +88 -0
- package/dist/index.js.map +1 -0
- package/dist/intent.d.ts +163 -0
- package/dist/intent.d.ts.map +1 -0
- package/dist/intent.js +82 -0
- package/dist/intent.js.map +1 -0
- package/dist/json.d.ts +49 -0
- package/dist/json.d.ts.map +1 -0
- package/dist/json.js +259 -0
- package/dist/json.js.map +1 -0
- package/dist/key.d.ts +64 -0
- package/dist/key.d.ts.map +1 -0
- package/dist/key.js +71 -0
- package/dist/key.js.map +1 -0
- package/dist/payment.d.ts +172 -0
- package/dist/payment.d.ts.map +1 -0
- package/dist/payment.js +178 -0
- package/dist/payment.js.map +1 -0
- package/dist/refusal.d.ts +121 -0
- package/dist/refusal.d.ts.map +1 -0
- package/dist/refusal.js +176 -0
- package/dist/refusal.js.map +1 -0
- package/dist/retry.d.ts +215 -0
- package/dist/retry.d.ts.map +1 -0
- package/dist/retry.js +297 -0
- package/dist/retry.js.map +1 -0
- package/dist/typeid.d.ts +64 -0
- package/dist/typeid.d.ts.map +1 -0
- package/dist/typeid.js +119 -0
- package/dist/typeid.js.map +1 -0
- package/dist/types.d.ts +245 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +14 -0
- package/dist/types.js.map +1 -0
- package/package.json +58 -3
package/dist/client.js
ADDED
|
@@ -0,0 +1,1303 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The client, and the one request funnel every call goes through.
|
|
3
|
+
*
|
|
4
|
+
* @packageDocumentation
|
|
5
|
+
*/
|
|
6
|
+
import { ConfigError, DeadlineExceededError, DecodeError, EndpointsExhaustedError, MissingAttestationError, NoActivePolicyError, StalePolicyError, TransportError, ConnectError, RATE_LIMIT_EXCEEDED, RateLimitedError, UnexpectedResponseError, IdempotencyWindowError, errorFromBody, isErrorBody, } from "./errors.js";
|
|
7
|
+
import { ensureFresh, mintedAtWire, readIdempotencyWindow } from "./idempotency.js";
|
|
8
|
+
export { mintedAtWire } from "./idempotency.js";
|
|
9
|
+
import { endedAfterRedrive, redrivePause, redrives, runAuthorize, } from "./authorize.js";
|
|
10
|
+
import { Failover, describeRefusal, endpointSet, onEndpoint, outcomeOfStatus, outcomeOfTransport, } from "./failover.js";
|
|
11
|
+
import { pinIntent, settleDraft } from "./intent.js";
|
|
12
|
+
import { parseJson, stringifyJson } from "./json.js";
|
|
13
|
+
import { AUTHORIZE_DEADLINE_MS, AUTHORIZE_RESENT_STATUSES, AUTHORIZE_RETRY_POLICY, SUBMIT_ATTEMPT_FALLBACK_MS, SUBMIT_TIMEOUT_HEADER, advertisedCommitWait, attemptTimeout, liftedSubmitTimeout, checkRetryPolicy, isConnectFailure, parseRetryAfter, submitAttemptTimeout, } from "./retry.js";
|
|
14
|
+
/**
|
|
15
|
+
* How many policies {@link AxorumClient.submitPinned} will pin against before giving up.
|
|
16
|
+
*
|
|
17
|
+
* A bound, not a retry budget: each attempt pins against the policy the service *just
|
|
18
|
+
* told us* is in force, so a second failure means the policy moved again in the
|
|
19
|
+
* microseconds since. Three is generous; a service changing policy faster than that is
|
|
20
|
+
* not one a client can chase, and looping forever would turn a hot policy rotation into
|
|
21
|
+
* a self-inflicted denial of service.
|
|
22
|
+
*/
|
|
23
|
+
export const MAX_PINS = 3;
|
|
24
|
+
/** The agent plane's mount point. Not configurable — it is part of the frozen contract. */
|
|
25
|
+
const BASE_PATH = "/api/v1";
|
|
26
|
+
/** The default per-request timeout, in milliseconds. */
|
|
27
|
+
const DEFAULT_TIMEOUT_MS = 30_000;
|
|
28
|
+
/**
|
|
29
|
+
* The statuses the agent plane documents as answers. A client's retry policy never
|
|
30
|
+
* re-sends one: the identical request would be answered identically, or (`421`, `503
|
|
31
|
+
* commit_unavailable`) the answer is the caller's to act on. The Rust SDK registers the
|
|
32
|
+
* same set with `acton-service-client` as accepted statuses, which is what keeps them
|
|
33
|
+
* out of its retry loop. `authorize` has the transport re-send `421` regardless
|
|
34
|
+
* ({@link AUTHORIZE_RESENT_STATUSES}), and sends a `503` again itself, its body read.
|
|
35
|
+
*/
|
|
36
|
+
const CONTRACT_STATUSES = new Set([400, 401, 403, 404, 409, 421, 422, 500, 503, 507]);
|
|
37
|
+
/**
|
|
38
|
+
* A typed client for the Axorum agent plane.
|
|
39
|
+
*
|
|
40
|
+
* # Writes carry their credential; so do reads
|
|
41
|
+
*
|
|
42
|
+
* An intent authenticates **in the envelope**: the draft names the `agent://` URI and the
|
|
43
|
+
* PASETO attestation, and the service verifies them before it proposes anything. The
|
|
44
|
+
* reads used to carry nothing at all, and were answered anyway — so any party's
|
|
45
|
+
* obligations were enumerable, and any observed transaction id disclosed another tenant's
|
|
46
|
+
* record.
|
|
47
|
+
*
|
|
48
|
+
* They are credentialed now, with the **same token**: the party-scoped reads present the
|
|
49
|
+
* agent's attestation as `Authorization: Bearer …`, the service takes the reader's
|
|
50
|
+
* identity from that token's verified `agent_uri` claim, and answers only within the
|
|
51
|
+
* party it is bound to. There is no second credential to obtain — an agent that can
|
|
52
|
+
* submit can already read.
|
|
53
|
+
*
|
|
54
|
+
* So a client that reads must hold an attestation:
|
|
55
|
+
*
|
|
56
|
+
* ```ts
|
|
57
|
+
* const axorum = new AxorumClient({ baseUrl: "http://127.0.0.1:8080", attestation: paseto });
|
|
58
|
+
*
|
|
59
|
+
* const outcome = await axorum.submitPinned(draft);
|
|
60
|
+
*
|
|
61
|
+
* // A refusal RESOLVES. Branch on the verdict, never on a status code.
|
|
62
|
+
* if (outcome.posted) {
|
|
63
|
+
* console.log("permitted, and the entries posted");
|
|
64
|
+
* } else {
|
|
65
|
+
* console.log("refused, and the refusal is on the ledger:", outcome.verdict);
|
|
66
|
+
* }
|
|
67
|
+
*
|
|
68
|
+
* // Reads are scoped to the party the attestation is bound to.
|
|
69
|
+
* const owed = await axorum.obligations();
|
|
70
|
+
* ```
|
|
71
|
+
*
|
|
72
|
+
* A client built **without** one still submits and still reads {@link activePolicy} —
|
|
73
|
+
* both carry their own credential or need none — but any party-scoped read is a
|
|
74
|
+
* {@link MissingAttestationError}, raised locally, without a wasted round trip.
|
|
75
|
+
*
|
|
76
|
+
* An {@link AxorumError} means the ledger never got to answer at all.
|
|
77
|
+
*/
|
|
78
|
+
export class AxorumClient {
|
|
79
|
+
#baseUrl;
|
|
80
|
+
/** The failover URLs as given, handed on by {@link withAttestation}. */
|
|
81
|
+
#failoverUrls;
|
|
82
|
+
/** Every endpoint's origin, `scheme://host:port`: the base URL's first. */
|
|
83
|
+
#endpoints;
|
|
84
|
+
#onRotation;
|
|
85
|
+
#onRetry;
|
|
86
|
+
/**
|
|
87
|
+
* The endpoint the next call starts at: the last one that gave a definitive answer.
|
|
88
|
+
* Shared with every client {@link withAttestation} derives.
|
|
89
|
+
*/
|
|
90
|
+
#preferred = { value: 0 };
|
|
91
|
+
#timeoutMs;
|
|
92
|
+
/** Whether {@link #timeoutMs} is the SDK's default, which a plain submission may lift. */
|
|
93
|
+
#defaultTimeout;
|
|
94
|
+
#fetch;
|
|
95
|
+
/**
|
|
96
|
+
* The caller's PASETO attestation, presented on the party-scoped reads.
|
|
97
|
+
*
|
|
98
|
+
* Held here rather than baked into the funnel's default headers for one reason:
|
|
99
|
+
* **attestations expire**. A token welded on at construction would mean rebuilding the
|
|
100
|
+
* client — and with it whatever the `fetch` it was handed is holding open — to rotate
|
|
101
|
+
* one. Kept beside it, it can be swapped with {@link withAttestation} for the price of
|
|
102
|
+
* a string.
|
|
103
|
+
*/
|
|
104
|
+
#attestation;
|
|
105
|
+
#retry;
|
|
106
|
+
/** The budget of one {@link authorize} call, at most {@link AUTHORIZE_DEADLINE_MS}. */
|
|
107
|
+
#authorizeDeadlineMs;
|
|
108
|
+
/** Whether {@link authorize} re-sends an unanswered request. */
|
|
109
|
+
#authorizeRetries;
|
|
110
|
+
/**
|
|
111
|
+
* The policy {@link authorize} last pinned, shared with every client
|
|
112
|
+
* {@link withAttestation} derives, so one rotation costs one `409` for all of them.
|
|
113
|
+
*/
|
|
114
|
+
#pin = { value: undefined };
|
|
115
|
+
/**
|
|
116
|
+
* What the service advertised about itself (its contract version and, when clustered,
|
|
117
|
+
* its commit wait), once read, shared with every client {@link withAttestation}
|
|
118
|
+
* derives. A deployment does not change contract under a running client without a
|
|
119
|
+
* restart; a commit wait changed by a restart is picked up by a new client.
|
|
120
|
+
*/
|
|
121
|
+
/**
|
|
122
|
+
* The idempotency window the service stated with a wall-clock length, once read,
|
|
123
|
+
* shared with every client {@link withAttestation} derives. Kept only when clocked:
|
|
124
|
+
* the window only ever grows, and an unclocked or unstated one is read again.
|
|
125
|
+
*/
|
|
126
|
+
#window = { value: undefined };
|
|
127
|
+
/**
|
|
128
|
+
* The commit wait the service advertised ({@link SUBMIT_TIMEOUT_HEADER}), in
|
|
129
|
+
* milliseconds, once read, shared with every client {@link withAttestation} derives:
|
|
130
|
+
* `null` when it advertises none, as a standalone service does.
|
|
131
|
+
*/
|
|
132
|
+
#commitWait = { value: undefined };
|
|
133
|
+
/** Whether the commit-wait fallback has been noted to {@link #debug}, shared as {@link #commitWait} is. */
|
|
134
|
+
#fallbackNoted = { value: false };
|
|
135
|
+
#debug;
|
|
136
|
+
/**
|
|
137
|
+
* Builds a client for the service rooted at `baseUrl`.
|
|
138
|
+
*
|
|
139
|
+
* @throws {ConfigError} If `baseUrl` is not an absolute `http`/`https` URL, a failover
|
|
140
|
+
* URL is not a bare origin of the same scheme or repeats one, the timeout is not a
|
|
141
|
+
* positive number, the retry policy is not whole numbers, or the authorize deadline
|
|
142
|
+
* is not a whole number of milliseconds from 1 to 15 000.
|
|
143
|
+
*/
|
|
144
|
+
constructor(options) {
|
|
145
|
+
this.#baseUrl = normalizeBaseUrl(options.baseUrl);
|
|
146
|
+
this.#failoverUrls = Object.freeze([...(options.failoverUrls ?? [])]);
|
|
147
|
+
const set = endpointSet(this.#baseUrl, this.#failoverUrls);
|
|
148
|
+
if ("refusal" in set) {
|
|
149
|
+
throw new ConfigError(`the Axorum client could not be built: ${describeRefusal(set.refusal)}`);
|
|
150
|
+
}
|
|
151
|
+
this.#endpoints = set.endpoints;
|
|
152
|
+
this.#onRotation = options.onRotation;
|
|
153
|
+
this.#onRetry = options.onRetry;
|
|
154
|
+
this.#attestation = options.attestation;
|
|
155
|
+
this.#timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
156
|
+
this.#defaultTimeout = options.timeoutMs === undefined;
|
|
157
|
+
this.#fetch = options.fetch ?? globalThis.fetch;
|
|
158
|
+
this.#retry = options.retry === undefined ? undefined : Object.freeze({ ...options.retry });
|
|
159
|
+
this.#authorizeDeadlineMs = options.authorizeDeadlineMs ?? AUTHORIZE_DEADLINE_MS;
|
|
160
|
+
this.#authorizeRetries = options.authorizeRetries ?? true;
|
|
161
|
+
this.#debug = options.debug;
|
|
162
|
+
if (!Number.isFinite(this.#timeoutMs) || this.#timeoutMs <= 0) {
|
|
163
|
+
throw new ConfigError(`the timeout must be a positive number of milliseconds, not ${options.timeoutMs}`);
|
|
164
|
+
}
|
|
165
|
+
if (this.#retry !== undefined) {
|
|
166
|
+
try {
|
|
167
|
+
checkRetryPolicy(this.#retry);
|
|
168
|
+
}
|
|
169
|
+
catch (error) {
|
|
170
|
+
throw new ConfigError(`retry: ${error.message}`);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
if (!validAuthorizeDeadline(this.#authorizeDeadlineMs)) {
|
|
174
|
+
throw new ConfigError(`the authorize deadline must be a whole number of milliseconds from 1 to ` +
|
|
175
|
+
`${AUTHORIZE_DEADLINE_MS}, not ${options.authorizeDeadlineMs}`);
|
|
176
|
+
}
|
|
177
|
+
if (this.#endpoints.length === 1) {
|
|
178
|
+
warnOneEndpoint(this.#baseUrl);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
/**
|
|
182
|
+
* The same client, reading as the bearer of a **different** attestation.
|
|
183
|
+
*
|
|
184
|
+
* Attestations expire, so an agent that outlives one of its tokens must be able to
|
|
185
|
+
* re-credential without rebuilding — and this hands the new client the *same* `fetch`,
|
|
186
|
+
* so whatever connections that `fetch` is keeping alive stay alive. Rotating a token
|
|
187
|
+
* costs a string, not a reconnect.
|
|
188
|
+
*
|
|
189
|
+
* It is also the honest way for one process to act for several agents: each gets its
|
|
190
|
+
* own view, and no view can see another's data, because the service scopes every read
|
|
191
|
+
* to the party the presented token names.
|
|
192
|
+
*/
|
|
193
|
+
withAttestation(attestation) {
|
|
194
|
+
const client = new AxorumClient({
|
|
195
|
+
baseUrl: this.#baseUrl,
|
|
196
|
+
failoverUrls: this.#failoverUrls,
|
|
197
|
+
...(this.#onRotation === undefined ? {} : { onRotation: this.#onRotation }),
|
|
198
|
+
...(this.#onRetry === undefined ? {} : { onRetry: this.#onRetry }),
|
|
199
|
+
attestation,
|
|
200
|
+
timeoutMs: this.#timeoutMs,
|
|
201
|
+
...(this.#retry === undefined ? {} : { retry: this.#retry }),
|
|
202
|
+
authorizeDeadlineMs: this.#authorizeDeadlineMs,
|
|
203
|
+
authorizeRetries: this.#authorizeRetries,
|
|
204
|
+
...(this.#debug === undefined ? {} : { debug: this.#debug }),
|
|
205
|
+
fetch: this.#fetch,
|
|
206
|
+
});
|
|
207
|
+
client.#pin = this.#pin;
|
|
208
|
+
client.#window = this.#window;
|
|
209
|
+
client.#defaultTimeout = this.#defaultTimeout;
|
|
210
|
+
client.#commitWait = this.#commitWait;
|
|
211
|
+
client.#fallbackNoted = this.#fallbackNoted;
|
|
212
|
+
client.#preferred = this.#preferred;
|
|
213
|
+
return client;
|
|
214
|
+
}
|
|
215
|
+
/**
|
|
216
|
+
* Asks the ledger whether `agent` may make `payment`, in one call.
|
|
217
|
+
*
|
|
218
|
+
* Proposes `purchase(amount, payee)` with the payee bound as **text**, debiting the
|
|
219
|
+
* default chart's spend account and crediting its payable account in the amount's
|
|
220
|
+
* currency. The policy in force must declare
|
|
221
|
+
* `action purchase(amount: amount, payee: text)`, and an operator must have opened the
|
|
222
|
+
* default chart in that currency. A rail governed by the same policy must be set to
|
|
223
|
+
* `payee_binding = "text"`; one left on `"party"` binds a different type.
|
|
224
|
+
*
|
|
225
|
+
* ```ts
|
|
226
|
+
* import { Agent, AxorumClient, AuthorizeError, payment } from "@axorum/client";
|
|
227
|
+
*
|
|
228
|
+
* const axorum = new AxorumClient({ baseUrl: "https://ledger.acme.com" });
|
|
229
|
+
* const buyer = new Agent("agent://acme.com/agent/buyer_01h455vb4pex5vsknk084sn02q", paseto);
|
|
230
|
+
* const lunch = payment({ amount: { minor: 1_250n, currency: "USD" }, payee: "api.openai.com" });
|
|
231
|
+
*
|
|
232
|
+
* const answer = await axorum.authorize(buyer, lunch);
|
|
233
|
+
* if (answer.allowed) {
|
|
234
|
+
* await pay();
|
|
235
|
+
* } else {
|
|
236
|
+
* console.log(answer.refusal.explanation);
|
|
237
|
+
* }
|
|
238
|
+
* ```
|
|
239
|
+
*
|
|
240
|
+
* # A refusal resolves
|
|
241
|
+
*
|
|
242
|
+
* A payment the policy forbids resolves as `{ allowed: false, refusal }`: it was judged
|
|
243
|
+
* and recorded, and nothing posted. A rejected promise ({@link AuthorizeError}) means the
|
|
244
|
+
* ledger never answered.
|
|
245
|
+
*
|
|
246
|
+
* # Retries and time
|
|
247
|
+
*
|
|
248
|
+
* One call has one budget, {@link AUTHORIZE_DEADLINE_MS} (15 s, or less with
|
|
249
|
+
* {@link AxorumClientOptions.authorizeDeadlineMs}), counted from the call and shared by
|
|
250
|
+
* the idempotency window and policy reads, every submission (the first, and each
|
|
251
|
+
* re-pin), every attempt and every pause. Each submission attempt waits the commit
|
|
252
|
+
* wait the service advertises plus 1 s ({@link submitAttemptTimeout}), so a clustered
|
|
253
|
+
* service's own `503 commit_unavailable` is heard rather than cut off, or 6 s
|
|
254
|
+
* ({@link SUBMIT_ATTEMPT_FALLBACK_MS}) when it advertises none; each read waits 6 s.
|
|
255
|
+
* Every attempt is also clamped to what is left of the budget. When that clamp cuts a
|
|
256
|
+
* submission attempt below the commit wait plus 1 s, the service may still be deciding
|
|
257
|
+
* when the client stops waiting, so a call that ends there is honestly
|
|
258
|
+
* `indeterminate`. The advertisement is read once per client, from
|
|
259
|
+
* `GET /api/openapi.json`.
|
|
260
|
+
* Re-sends follow {@link AUTHORIZE_RETRY_POLICY}: as many as the budget allows, full
|
|
261
|
+
* jitter floored at 100 ms and capped at 1.6 s, honouring `Retry-After` within the
|
|
262
|
+
* budget. The client's own `retry` and `timeoutMs` govern its other calls, not this one;
|
|
263
|
+
* {@link AxorumClientOptions.authorizeRetries} turns re-sending off.
|
|
264
|
+
*
|
|
265
|
+
* | Re-sent until the deadline | Not re-sent |
|
|
266
|
+
* |---|---|
|
|
267
|
+
* | connection failures and timeouts | refusals (they are answers) |
|
|
268
|
+
* | `429`, `502`, `503`, `504` (and `423` with `Retry-After`) | `422`, including a missing chart or an unknown payee |
|
|
269
|
+
* | `421 not_primary`, on the next {@link AxorumClientOptions.failoverUrls} URL | `409 transaction_reused` |
|
|
270
|
+
* | `409` stale pin: re-pinned to `active`, at most 3 times | `400` / `401` / `403` / `404` / `507` |
|
|
271
|
+
*
|
|
272
|
+
* Every send carries the payment's transaction id, so one that did commit replays its
|
|
273
|
+
* answer instead of paying twice. A connection failure, a timeout or a `421` moves the
|
|
274
|
+
* next attempt to the next failover URL at once; a `503` is sent again to the same URL,
|
|
275
|
+
* its body read. Whatever is still unanswered
|
|
276
|
+
* when the budget runs out (a `421` and a `503` included) is `indeterminate`: re-drive
|
|
277
|
+
* the same {@link Payment} to learn what happened. A call that proves it wrote nothing
|
|
278
|
+
* is `not_submitted`. `primaryApiAddr` is a hint, never followed.
|
|
279
|
+
*
|
|
280
|
+
* Once an attempt ends without a definite answer (a timeout, or a re-sent status other
|
|
281
|
+
* than `429`), it may have committed, so a later failure that is not the ledger's own
|
|
282
|
+
* answer about the transaction id (a `403`, a `401`, a `429` or `502` once retries are
|
|
283
|
+
* spent) is `indeterminate` too, with cause `earlier_attempt_in_doubt`. A
|
|
284
|
+
* `409 transaction_reused` and a `422 substrate_rejected` stay what they are.
|
|
285
|
+
*
|
|
286
|
+
* # Time
|
|
287
|
+
*
|
|
288
|
+
* The attestation is read once per submission (the first, and each re-pin), and its
|
|
289
|
+
* `exp` bounds that submission too: every send, and every wait between sends
|
|
290
|
+
* (`Retry-After` included), ends 30 seconds (`ATTESTATION_CLOCK_ALLOWANCE_MS`) before
|
|
291
|
+
* `exp` when that comes before the call's deadline. No send starts past that point, each
|
|
292
|
+
* send's timeout is cut to the time left, and a wait that would reach it ends the
|
|
293
|
+
* re-sends.
|
|
294
|
+
*
|
|
295
|
+
* @throws {AuthorizeError} Carrying the transaction id and a `failure` that says what to
|
|
296
|
+
* do next.
|
|
297
|
+
* @throws The caller's own abort reason, as-is, when `options.signal` fires.
|
|
298
|
+
*/
|
|
299
|
+
async authorize(agent, payment, options) {
|
|
300
|
+
const lane = {
|
|
301
|
+
deadline: performance.now() + this.#authorizeDeadlineMs,
|
|
302
|
+
retries: this.#authorizeRetries,
|
|
303
|
+
};
|
|
304
|
+
const withSignal = (signal) => signal === undefined ? undefined : { signal };
|
|
305
|
+
return runAuthorize({
|
|
306
|
+
activePolicy: (signal) => this.#activePolicy(withSignal(signal), lane),
|
|
307
|
+
submit: (envelope, signal, bound) => this.#submitIntent(envelope, withSignal(signal), lane, bound),
|
|
308
|
+
pin: this.#pin,
|
|
309
|
+
keptWindow: () => this.#keptWindow(),
|
|
310
|
+
readWindow: (signal) => this.#readWindow(withSignal(signal), lane),
|
|
311
|
+
}, agent, payment, options?.signal);
|
|
312
|
+
}
|
|
313
|
+
/**
|
|
314
|
+
* Attaches the caller's attestation to a party-scoped read.
|
|
315
|
+
*
|
|
316
|
+
* The single choke point for read credentials: every scoped read goes through it, so
|
|
317
|
+
* none can forget. A client with no attestation fails **here**, locally, naming the
|
|
318
|
+
* omission — rather than sending a bare request and relaying the service's inevitable
|
|
319
|
+
* `401` as though the service had found fault with a credential that was never sent.
|
|
320
|
+
*
|
|
321
|
+
* @throws {MissingAttestationError} If this client holds none.
|
|
322
|
+
*/
|
|
323
|
+
#authorize(operation) {
|
|
324
|
+
if (this.#attestation === undefined) {
|
|
325
|
+
throw new MissingAttestationError(operation);
|
|
326
|
+
}
|
|
327
|
+
return { authorization: `Bearer ${this.#attestation}` };
|
|
328
|
+
}
|
|
329
|
+
/**
|
|
330
|
+
* The id of the policy currently in force, or `null` if none is.
|
|
331
|
+
*
|
|
332
|
+
* `null` is **not an error** — the service answers `200` with `{"policy": null}`, and
|
|
333
|
+
* "no policy is in force" is a true and useful fact about a ledger, not a failure to
|
|
334
|
+
* report one.
|
|
335
|
+
*
|
|
336
|
+
* This is the endpoint to re-read after a `409`; {@link submitPinned} does it for you.
|
|
337
|
+
*
|
|
338
|
+
* @throws {TransportError} If the service cannot be reached.
|
|
339
|
+
*/
|
|
340
|
+
/**
|
|
341
|
+
* The commit wait the service advertises, read once on `lane` when one is given and
|
|
342
|
+
* then kept for this client and the clients {@link withAttestation} derives.
|
|
343
|
+
* `undefined` when it advertises none, or when this read failed (the next submission
|
|
344
|
+
* reads again).
|
|
345
|
+
*/
|
|
346
|
+
async #commitWaitMs(options, lane) {
|
|
347
|
+
return (await this.#readCommitWait(options, lane)).ms;
|
|
348
|
+
}
|
|
349
|
+
/** {@link #commitWaitMs}, and the read's failure when it failed. */
|
|
350
|
+
async #readCommitWait(options, lane) {
|
|
351
|
+
if (this.#commitWait.value === undefined) {
|
|
352
|
+
try {
|
|
353
|
+
await this.#send({
|
|
354
|
+
method: "GET",
|
|
355
|
+
path: "api/openapi.json",
|
|
356
|
+
root: true,
|
|
357
|
+
options,
|
|
358
|
+
...(lane === undefined ? {} : { lane }),
|
|
359
|
+
headersOnly: true,
|
|
360
|
+
onHeaders: (headers) => this.#keepCommitWait(headers),
|
|
361
|
+
});
|
|
362
|
+
}
|
|
363
|
+
catch (error) {
|
|
364
|
+
// The caller's own cancellation is not a failed read: it ends the call.
|
|
365
|
+
if (options?.signal?.aborted === true) {
|
|
366
|
+
throw error;
|
|
367
|
+
}
|
|
368
|
+
return { ms: undefined, failure: error };
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
return { ms: this.#commitWait.value ?? undefined };
|
|
372
|
+
}
|
|
373
|
+
/** Keeps the commit wait `headers` state, unless one is kept already. */
|
|
374
|
+
#keepCommitWait(headers) {
|
|
375
|
+
this.#commitWait.value ??= advertisedCommitWait(headers.get(SUBMIT_TIMEOUT_HEADER)) ?? null;
|
|
376
|
+
}
|
|
377
|
+
/**
|
|
378
|
+
* The bound on a plain submission in place of the SDK's default timeout, when that is
|
|
379
|
+
* too short to outlast the commit wait the service advertises. A timeout the caller set
|
|
380
|
+
* is theirs, and nothing is read for it.
|
|
381
|
+
*/
|
|
382
|
+
async #liftedSubmit(options) {
|
|
383
|
+
if (!this.#defaultTimeout) {
|
|
384
|
+
return {};
|
|
385
|
+
}
|
|
386
|
+
const lifted = liftedSubmitTimeout(this.#timeoutMs, await this.#commitWaitMs(options, undefined));
|
|
387
|
+
return lifted === undefined ? {} : { attemptMs: lifted };
|
|
388
|
+
}
|
|
389
|
+
/**
|
|
390
|
+
* How long one submission attempt on `lane` may wait: {@link submitAttemptTimeout} of
|
|
391
|
+
* the advertised commit wait. The advertisement is read once per client; if that read
|
|
392
|
+
* fails, this attempt waits {@link SUBMIT_ATTEMPT_FALLBACK_MS} and the next submission
|
|
393
|
+
* reads again. The lane's deadline still clamps the attempt.
|
|
394
|
+
*
|
|
395
|
+
* The first time an attempt waits the fallback, this says why on the
|
|
396
|
+
* {@link AxorumClientOptions.debug} sink ({@link fallbackNote}), once per client.
|
|
397
|
+
*/
|
|
398
|
+
async #submitAttemptMs(options, lane) {
|
|
399
|
+
const { ms, failure } = await this.#readCommitWait(options, lane);
|
|
400
|
+
if (ms === undefined && !this.#fallbackNoted.value) {
|
|
401
|
+
this.#fallbackNoted.value = true;
|
|
402
|
+
this.#debug?.(fallbackNote(failure));
|
|
403
|
+
}
|
|
404
|
+
return submitAttemptTimeout(ms);
|
|
405
|
+
}
|
|
406
|
+
async activePolicy(options) {
|
|
407
|
+
return this.#activePolicy(options, undefined);
|
|
408
|
+
}
|
|
409
|
+
/** {@link activePolicy}, on `lane` when one is given. */
|
|
410
|
+
async #activePolicy(options, lane) {
|
|
411
|
+
const response = await this.#send({
|
|
412
|
+
method: "GET",
|
|
413
|
+
path: "policies/active",
|
|
414
|
+
options,
|
|
415
|
+
...(lane === undefined ? {} : { lane }),
|
|
416
|
+
});
|
|
417
|
+
return response.policy;
|
|
418
|
+
}
|
|
419
|
+
/**
|
|
420
|
+
* The ledger's currency registry: every declared currency and its minor-unit exponent,
|
|
421
|
+
* in canonical (sorted) code order.
|
|
422
|
+
*
|
|
423
|
+
* Requires an attestation, like every agent-plane read of ledger state, but is not
|
|
424
|
+
* owner-scoped: any bound agent reads the whole registry. From contract 5.0.0 an intent
|
|
425
|
+
* in any currency not listed here is refused with `422` and
|
|
426
|
+
* `cause: "undeclared_currency"`, so this is what to read to learn what the ledger will
|
|
427
|
+
* take. An empty list means nothing is declared.
|
|
428
|
+
*
|
|
429
|
+
* @throws {MissingAttestationError} If this client holds no attestation. Nothing is sent.
|
|
430
|
+
* @throws {AttestationError} If the service refuses the attestation.
|
|
431
|
+
* @throws {TransportError} If the service cannot be reached.
|
|
432
|
+
*/
|
|
433
|
+
async currencies(options) {
|
|
434
|
+
return this.#send({
|
|
435
|
+
method: "GET",
|
|
436
|
+
path: "currencies",
|
|
437
|
+
headers: this.#authorize("the currency registry"),
|
|
438
|
+
options,
|
|
439
|
+
});
|
|
440
|
+
}
|
|
441
|
+
/**
|
|
442
|
+
* How long this service answers a retry of a committed transaction id with the
|
|
443
|
+
* original outcome: its idempotency window (contract 5.0.0).
|
|
444
|
+
*
|
|
445
|
+
* Read from the three response headers on `GET /api/openapi.json`
|
|
446
|
+
* ({@link IDEMPOTENCY_WINDOW_HEADERS}); the document itself is the frozen contract and
|
|
447
|
+
* is not parsed. Needs no attestation. `window_seconds` is the window's wall-clock
|
|
448
|
+
* length (24 hours at least, rounded down), or `null` when the service runs no
|
|
449
|
+
* heartbeat and its window has no length in seconds. Read it once and hand it to
|
|
450
|
+
* {@link submitKeyed} or {@link ensureFresh}; it only ever grows.
|
|
451
|
+
*
|
|
452
|
+
* ```ts
|
|
453
|
+
* const window = await client.idempotencyWindow();
|
|
454
|
+
* window.window_seconds; // 86400n
|
|
455
|
+
* ```
|
|
456
|
+
*
|
|
457
|
+
* @throws {IdempotencyWindowError} If the headers state no window: a service older
|
|
458
|
+
* than contract 5.0.0, a writer that could not answer, or a rewritten header.
|
|
459
|
+
* @throws {UnexpectedResponseError} If the spec fetch does not answer `2xx`.
|
|
460
|
+
* @throws {TransportError} If the service cannot be reached.
|
|
461
|
+
*/
|
|
462
|
+
async idempotencyWindow(options) {
|
|
463
|
+
return this.#idempotencyWindow(options, undefined);
|
|
464
|
+
}
|
|
465
|
+
/** {@link idempotencyWindow}, on `lane` when one is given. */
|
|
466
|
+
async #idempotencyWindow(options, lane) {
|
|
467
|
+
let window;
|
|
468
|
+
await this.#send({
|
|
469
|
+
method: "GET",
|
|
470
|
+
path: "api/openapi.json",
|
|
471
|
+
root: true,
|
|
472
|
+
options,
|
|
473
|
+
...(lane === undefined ? {} : { lane }),
|
|
474
|
+
headersOnly: true,
|
|
475
|
+
onHeaders: (headers, status) => {
|
|
476
|
+
window = readIdempotencyWindow(headers, status);
|
|
477
|
+
this.#keepCommitWait(headers);
|
|
478
|
+
},
|
|
479
|
+
});
|
|
480
|
+
if (window === undefined) {
|
|
481
|
+
throw new IdempotencyWindowError(null, "not_advertised", null, null);
|
|
482
|
+
}
|
|
483
|
+
return window;
|
|
484
|
+
}
|
|
485
|
+
/**
|
|
486
|
+
* The wall-clock window this client last read, if it read one.
|
|
487
|
+
*
|
|
488
|
+
* The copy is a safe bound to hold a key to because the service never shrinks its
|
|
489
|
+
* window in ticks: boot raises the standing window to the configured one and never
|
|
490
|
+
* lowers it (`axorum-serve`'s `idempotency::boot_policy`), and the admin route refuses
|
|
491
|
+
* a shorter one with `409 idempotency_window_shrink`. What the copy can miss is served
|
|
492
|
+
* by the service's own check: a submission whose `minted_at_ms` is past its window,
|
|
493
|
+
* less a 5-minute skew margin, is refused with `409 stale_idempotency_key` and nothing
|
|
494
|
+
* is written. And the window can grow after the copy was taken, so a key the copy
|
|
495
|
+
* refuses is held to a fresh read before it is refused ({@link #readWindow}).
|
|
496
|
+
*/
|
|
497
|
+
#keptWindow() {
|
|
498
|
+
return this.#window.value;
|
|
499
|
+
}
|
|
500
|
+
/**
|
|
501
|
+
* Reads the service's window, and keeps it when it states a wall-clock length. An
|
|
502
|
+
* unclocked window is returned but not kept, so a heartbeat turned on is seen.
|
|
503
|
+
*/
|
|
504
|
+
async #readWindow(options, lane) {
|
|
505
|
+
const window = await this.#idempotencyWindow(options, lane);
|
|
506
|
+
if (window.window_seconds !== null) {
|
|
507
|
+
this.#window.value = window;
|
|
508
|
+
}
|
|
509
|
+
return window;
|
|
510
|
+
}
|
|
511
|
+
/**
|
|
512
|
+
* Submits an envelope under a retry key, refusing the key first if the service's
|
|
513
|
+
* window no longer covers it.
|
|
514
|
+
*
|
|
515
|
+
* Use this for every resubmission you make on your own schedule: a queue that
|
|
516
|
+
* redelivers, a job that restarts. Within the window a resubmission of the identical
|
|
517
|
+
* intent answers the original outcome (`replayed: true`); past it, the answer is
|
|
518
|
+
* undefined and the intent might commit twice, so a stale key is never sent.
|
|
519
|
+
*
|
|
520
|
+
* The envelope goes out with the key's `minted_at_ms`, so a contract 5.0.0 service
|
|
521
|
+
* that runs a heartbeat refuses it too (`409 stale_idempotency_key`, an
|
|
522
|
+
* {@link ApiError}) once it is older than the window less a 5-minute clock-skew
|
|
523
|
+
* margin. A caller-chosen id restated with its true mint time passes that check
|
|
524
|
+
* exactly as a freshly minted one does.
|
|
525
|
+
*
|
|
526
|
+
* # Why re-sending is sound
|
|
527
|
+
*
|
|
528
|
+
* The service looks a transaction id up before it applies anything. A re-send inside
|
|
529
|
+
* the idempotency window returns what the first send committed, and one whose content
|
|
530
|
+
* differs is refused as `409 transaction_reused`, never applied.
|
|
531
|
+
*
|
|
532
|
+
* # Retries and time
|
|
533
|
+
*
|
|
534
|
+
* It runs on {@link authorize}'s transport: one budget of `authorizeDeadlineMs`
|
|
535
|
+
* ({@link AUTHORIZE_DEADLINE_MS} by default), shared by the read of the service's
|
|
536
|
+
* commit wait and every attempt, each bounded by {@link submitAttemptTimeout} of that
|
|
537
|
+
* wait, moving across the `failoverUrls` on a connection failure, a timeout and `421`,
|
|
538
|
+
* and sending a `503` again itself, its body read. `authorizeRetries: false` turns
|
|
539
|
+
* re-sending off here too.
|
|
540
|
+
*
|
|
541
|
+
* | Call | Re-sent? | Moves to the next URL? |
|
|
542
|
+
* |---|---|---|
|
|
543
|
+
* | {@link submitIntent} | no | no |
|
|
544
|
+
* | {@link submitPinned} | only to re-pin after a `409` | no |
|
|
545
|
+
* | `submitKeyed` | yes, until the deadline | yes |
|
|
546
|
+
*
|
|
547
|
+
* ```ts
|
|
548
|
+
* const window = await client.idempotencyWindow();
|
|
549
|
+
* const key = IdempotencyKey.mint();
|
|
550
|
+
* const envelope = pinIntent({ ...draft, transaction: key.transaction }, policy);
|
|
551
|
+
* const outcome = await client.submitKeyed(key, window, envelope);
|
|
552
|
+
* ```
|
|
553
|
+
*
|
|
554
|
+
* @throws {StaleIdempotencyKeyError} If the key is past the window, or the service
|
|
555
|
+
* advertises no wall-clock window. Nothing was sent.
|
|
556
|
+
* @throws {ConfigError} If the envelope's `transaction` is not the key's. Nothing was
|
|
557
|
+
* sent.
|
|
558
|
+
* @throws {TransactionReusedError} If the id committed with different content.
|
|
559
|
+
* @throws {EndpointsExhaustedError | DeadlineExceededError} When no endpoint answered
|
|
560
|
+
* in time. Otherwise as {@link submitIntent}.
|
|
561
|
+
*/
|
|
562
|
+
async submitKeyed(key, window, envelope, options) {
|
|
563
|
+
checkKeyed(key, window, envelope, Date.now());
|
|
564
|
+
const lane = {
|
|
565
|
+
deadline: performance.now() + this.#authorizeDeadlineMs,
|
|
566
|
+
retries: this.#authorizeRetries,
|
|
567
|
+
};
|
|
568
|
+
return this.#submitIntent({ ...envelope, minted_at_ms: mintedAtWire(key) }, options, lane);
|
|
569
|
+
}
|
|
570
|
+
/**
|
|
571
|
+
* Submits an intent envelope and returns the ledger's outcome.
|
|
572
|
+
*
|
|
573
|
+
* # A refusal resolves
|
|
574
|
+
*
|
|
575
|
+
* If the policy in force forbids the action, this **resolves** with
|
|
576
|
+
* `{ posted: false, verdict: "ForbiddenRejected", … }` — a `200`. The intent reached
|
|
577
|
+
* the commit point, was judged, and the refusal was *recorded*. That record is the
|
|
578
|
+
* product. Branch on `posted`/`verdict`; a rejected promise means the ledger never got
|
|
579
|
+
* to answer at all.
|
|
580
|
+
*
|
|
581
|
+
* Re-submitting the identical intent under a `transaction` id that already committed
|
|
582
|
+
* replays its stored outcome, marked `replayed: true`, rather than double-posting: the
|
|
583
|
+
* id is the substrate's idempotency key, which is what makes retrying a submission
|
|
584
|
+
* safe within the service's window ({@link idempotencyWindow}). Different content
|
|
585
|
+
* under a committed id is a {@link TransactionReusedError}. A replay also carries
|
|
586
|
+
* `content_verified`: `true` when the resubmitted content matched the stored intent,
|
|
587
|
+
* `false` when the service had no stored content to compare with (an id committed
|
|
588
|
+
* before 1.0.0 whose content hash the old log never recorded, retried inside the
|
|
589
|
+
* window after the upgrade). A `false` replay posted nothing, but it did not check
|
|
590
|
+
* your content either. To have the key's age checked before sending, use
|
|
591
|
+
* {@link submitKeyed}.
|
|
592
|
+
*
|
|
593
|
+
* @throws {ConfigError} If the envelope carries no `minted_at_ms` (a non-negative
|
|
594
|
+
* `bigint` of milliseconds since the Unix epoch). Nothing was sent.
|
|
595
|
+
* @throws {StalePolicyError} If the pin did not hold. Use {@link submitPinned} to
|
|
596
|
+
* handle that automatically.
|
|
597
|
+
* @throws {UnknownAgentError | AttestationError} On an identity or authority failure.
|
|
598
|
+
* @throws {NotPrimaryError} If a follower turned the mutation away. Not followed
|
|
599
|
+
* automatically — see the class docs.
|
|
600
|
+
* @throws {CommitUnavailableError} If a cluster commit could not be confirmed.
|
|
601
|
+
* @throws {TransportError} If the service cannot be reached.
|
|
602
|
+
*/
|
|
603
|
+
async submitIntent(envelope, options) {
|
|
604
|
+
return this.#submitIntent(envelope, options, undefined);
|
|
605
|
+
}
|
|
606
|
+
/**
|
|
607
|
+
* {@link submitIntent}, on `lane` and within `bound` when they are given. On the lane,
|
|
608
|
+
* with retries on, a `503` is sent again by this loop, its body read ({@link redrives}),
|
|
609
|
+
* under {@link AUTHORIZE_RETRY_POLICY} until the deadline; `bound` hears each, and so
|
|
610
|
+
* does {@link AxorumClientOptions.onRetry}.
|
|
611
|
+
*
|
|
612
|
+
* The loop exists only because a transport re-send reports a `503`'s status and not its
|
|
613
|
+
* code, so an observer cannot tell `idempotency_unseeded`, which wrote nothing, from
|
|
614
|
+
* `commit_unavailable` (https://github.com/Govcraft/acton-service-client/issues/9, which
|
|
615
|
+
* the TypeScript port mirrors). Once the retried response's code reaches the observer,
|
|
616
|
+
* it collapses into retry-policy configuration.
|
|
617
|
+
*/
|
|
618
|
+
async #submitIntent(envelope, options, lane, bound) {
|
|
619
|
+
checkMinted(envelope);
|
|
620
|
+
const deadline = earliest(lane?.deadline, bound?.deadline);
|
|
621
|
+
let pausedAfter;
|
|
622
|
+
for (let redriven = 0;; redriven += 1) {
|
|
623
|
+
let endpoint;
|
|
624
|
+
try {
|
|
625
|
+
return await this.#send({
|
|
626
|
+
method: "POST",
|
|
627
|
+
path: "intents",
|
|
628
|
+
body: envelope,
|
|
629
|
+
options,
|
|
630
|
+
...(bound === undefined ? {} : { bound }),
|
|
631
|
+
onEnded: (at) => {
|
|
632
|
+
endpoint = at;
|
|
633
|
+
},
|
|
634
|
+
// Each attempt outlasts the service's commit wait, so its `503 commit_unavailable`
|
|
635
|
+
// is heard rather than cut off; on `authorize`'s lane the deadline still clamps it.
|
|
636
|
+
...(lane === undefined
|
|
637
|
+
? await this.#liftedSubmit(options)
|
|
638
|
+
: { lane, attemptMs: await this.#submitAttemptMs(options, lane) }),
|
|
639
|
+
});
|
|
640
|
+
}
|
|
641
|
+
catch (thrown) {
|
|
642
|
+
const ended = endedAfterRedrive(pausedAfter, thrown);
|
|
643
|
+
if (ended.heard) {
|
|
644
|
+
throw ended.error;
|
|
645
|
+
}
|
|
646
|
+
const error = ended.error;
|
|
647
|
+
const wait = lane?.retries === true && deadline !== undefined && redrives(error)
|
|
648
|
+
? redrivePause(redriven, Math.random(), Math.max(0, deadline - performance.now()))
|
|
649
|
+
: undefined;
|
|
650
|
+
if (wait === undefined) {
|
|
651
|
+
if (endpoint !== undefined) {
|
|
652
|
+
bound?.onEnded?.(endpoint);
|
|
653
|
+
}
|
|
654
|
+
throw error;
|
|
655
|
+
}
|
|
656
|
+
if (endpoint !== undefined) {
|
|
657
|
+
bound?.onRedrive?.(endpoint, error);
|
|
658
|
+
this.#observe({ kind: "retry", reason: { kind: "status", status: 503 } }, this.#endpoints.indexOf(endpoint), redriven + 2);
|
|
659
|
+
}
|
|
660
|
+
pausedAfter = error;
|
|
661
|
+
await pause(wait, options?.signal);
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
/**
|
|
666
|
+
* Fetches the active policy, pins the draft to it, submits — and if the pin did not
|
|
667
|
+
* hold, re-pins and submits again.
|
|
668
|
+
*
|
|
669
|
+
* This is the loop every correct agent writes, so it ships in the client rather than
|
|
670
|
+
* in each caller. It is the *only* method that recovers automatically, and it is safe
|
|
671
|
+
* to do so for one specific reason: the `transaction` id is minted **once**, before
|
|
672
|
+
* the first attempt, and is the substrate's idempotency key. Every retry carries the
|
|
673
|
+
* same id, so if an earlier attempt in fact landed, the retry replays its stored
|
|
674
|
+
* outcome. The loop cannot double-post.
|
|
675
|
+
*
|
|
676
|
+
* A refusal still resolves (see {@link submitIntent}), and a `421` still rejects:
|
|
677
|
+
* following a redirect to another replica is the caller's decision, not the client's.
|
|
678
|
+
*
|
|
679
|
+
* @throws {NoActivePolicyError} If no policy is in force — there is nothing to pin to.
|
|
680
|
+
* @throws {StalePolicyError} If the policy kept moving out from under {@link MAX_PINS}
|
|
681
|
+
* attempts.
|
|
682
|
+
*/
|
|
683
|
+
async submitPinned(draft, options) {
|
|
684
|
+
const policy = await this.activePolicy(options);
|
|
685
|
+
if (policy === null) {
|
|
686
|
+
throw new NoActivePolicyError("no policy is in force, so an intent cannot be pinned to one");
|
|
687
|
+
}
|
|
688
|
+
return this.submitPinnedFrom(draft, policy, options);
|
|
689
|
+
}
|
|
690
|
+
/**
|
|
691
|
+
* {@link submitPinned}, but starting from a policy you already believe is in force —
|
|
692
|
+
* and recovering if you are wrong.
|
|
693
|
+
*
|
|
694
|
+
* This is the shape a long-running agent actually wants. Reading the active policy
|
|
695
|
+
* before *every* submission is a round trip spent re-learning something that changes
|
|
696
|
+
* rarely; an agent can hold the last policy it saw, submit straight against it, and
|
|
697
|
+
* let the `409` tell it when its belief went stale. The recovery is the same loop
|
|
698
|
+
* either way, so caching costs nothing in correctness — the service remains the
|
|
699
|
+
* authority, it is simply consulted only when it disagrees.
|
|
700
|
+
*
|
|
701
|
+
* On a `409` it re-pins against the policy **the rejection itself named**, rather than
|
|
702
|
+
* re-reading the active policy. That is not a shortcut: the rejection is the service's
|
|
703
|
+
* own statement of what is in force as of the moment it refused — strictly fresher
|
|
704
|
+
* than anything a follow-up read could return, and one round trip cheaper. Bounded at
|
|
705
|
+
* {@link MAX_PINS} attempts.
|
|
706
|
+
*
|
|
707
|
+
* @param draft - The intent, minus the pin. Its transaction id and mint time are
|
|
708
|
+
* settled once, here, before the first attempt, and every re-pin carries both.
|
|
709
|
+
* @param policyId - The policy you believe is in force.
|
|
710
|
+
* @throws {StalePolicyError} If the policy kept moving out from under {@link MAX_PINS}
|
|
711
|
+
* attempts — the policy is churning faster than a client can chase it, and looping
|
|
712
|
+
* on would turn a hot rotation into a denial of service.
|
|
713
|
+
*/
|
|
714
|
+
async submitPinnedFrom(draft, policyId, options) {
|
|
715
|
+
// Once, before the first attempt — never inside the loop. This single line is what
|
|
716
|
+
// makes every retry below a replay rather than a second posting, and what makes every
|
|
717
|
+
// retry carry the same mint time.
|
|
718
|
+
const settled = settleDraft(draft);
|
|
719
|
+
let policy = policyId;
|
|
720
|
+
for (let attempt = 1;; attempt += 1) {
|
|
721
|
+
try {
|
|
722
|
+
// The refusal path returns from here too, as a resolved value — as it should.
|
|
723
|
+
return await this.submitIntent(pinIntent(settled, policy), options);
|
|
724
|
+
}
|
|
725
|
+
catch (error) {
|
|
726
|
+
// Not a pin problem, so not ours to fix: an attestation failure, a 421, an
|
|
727
|
+
// unreachable service. Hand it straight back. And out of attempts is out of
|
|
728
|
+
// attempts: report the last rejection as the service stated it.
|
|
729
|
+
if (!(error instanceof StalePolicyError) || attempt >= MAX_PINS) {
|
|
730
|
+
throw error;
|
|
731
|
+
}
|
|
732
|
+
// The service named the policy now in force. Pin to it and go again.
|
|
733
|
+
policy = error.active;
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
/**
|
|
738
|
+
* The stored record for a committed transaction — **if your party proposed it**.
|
|
739
|
+
*
|
|
740
|
+
* Reading it is the idempotent replay lookup: it tells you what a previously-submitted
|
|
741
|
+
* `transaction` id did, without resubmitting it. A recorded refusal is here like any
|
|
742
|
+
* other record, with `posted: false`.
|
|
743
|
+
*
|
|
744
|
+
* The record is {@link TransactionRecord | contractually opaque} in this version of
|
|
745
|
+
* the wire contract.
|
|
746
|
+
*
|
|
747
|
+
* # A transaction id is not a capability
|
|
748
|
+
*
|
|
749
|
+
* Ids are client-minted, and a counterparty may well know one. Holding an id therefore
|
|
750
|
+
* grants nothing: a transaction another party proposed answers `404`, *identically* to
|
|
751
|
+
* an id that never existed. The two are deliberately indistinguishable — a
|
|
752
|
+
* distinguishable "forbidden" would turn any id into an existence oracle against
|
|
753
|
+
* another tenant's ledger.
|
|
754
|
+
*
|
|
755
|
+
* @throws {MissingAttestationError} If this client holds no attestation.
|
|
756
|
+
* @throws {ApiError} With code `unknown_transaction` (404) if there is no such
|
|
757
|
+
* transaction **or it belongs to another party**.
|
|
758
|
+
*/
|
|
759
|
+
async transaction(txnId, options) {
|
|
760
|
+
return this.#send({
|
|
761
|
+
method: "GET",
|
|
762
|
+
path: `transactions/${encodeURIComponent(txnId)}`,
|
|
763
|
+
headers: this.#authorize("a transaction"),
|
|
764
|
+
options,
|
|
765
|
+
});
|
|
766
|
+
}
|
|
767
|
+
/**
|
|
768
|
+
* The **signed receipt** for a transaction your party proposed — an
|
|
769
|
+
* `axorum.evidence.v0` envelope whose base64 `payload` is the
|
|
770
|
+
* `axorum.receipt.v0` facts of the applied record (contract 4.1.0).
|
|
771
|
+
*
|
|
772
|
+
* Re-minted deterministically from the stored record on every call:
|
|
773
|
+
* byte-identical across fetches and to the inline `receipt` the original
|
|
774
|
+
* submission's outcome carried. Verify it **offline** against the key list
|
|
775
|
+
* published at `GET /api/verification-keys.json` — no credential, no server,
|
|
776
|
+
* no SDK required (see `spec/schemas/` and `spec/evidence-signing.md`).
|
|
777
|
+
*
|
|
778
|
+
* @throws {MissingAttestationError} If this client holds no attestation.
|
|
779
|
+
* @throws {ApiError} With `unknown_transaction` (404) if there is no such
|
|
780
|
+
* transaction, it belongs to another party, **or** its record has been
|
|
781
|
+
* archived out of the live tail — deliberately indistinguishable. With
|
|
782
|
+
* `signing_not_configured` / `signing_unavailable` (503) when the
|
|
783
|
+
* deployment does not sign or its signing service is down (the
|
|
784
|
+
* transaction itself is committed and unaffected — retry later).
|
|
785
|
+
*/
|
|
786
|
+
async receipt(txnId, options) {
|
|
787
|
+
return this.#send({
|
|
788
|
+
method: "GET",
|
|
789
|
+
path: `transactions/${encodeURIComponent(txnId)}/receipt`,
|
|
790
|
+
headers: this.#authorize("a receipt"),
|
|
791
|
+
options,
|
|
792
|
+
});
|
|
793
|
+
}
|
|
794
|
+
/**
|
|
795
|
+
* The open obligations **your party** owes.
|
|
796
|
+
*
|
|
797
|
+
* Whose duties these are is decided by the attestation, never by an argument. That is
|
|
798
|
+
* the whole of the fix: `?actor=` used to *select* the party — through a deterministic,
|
|
799
|
+
* public hash of a name — so every party's duties were enumerable by anyone who could
|
|
800
|
+
* guess a name, with no credential at all.
|
|
801
|
+
*
|
|
802
|
+
* `actor` is now an assertion the service **checks** rather than obeys. Leave it out and
|
|
803
|
+
* you are answered for the party your token names. Supply it — a party id (`party_…`) or
|
|
804
|
+
* a DSL party name — and the service confirms it resolves to that same party, answering
|
|
805
|
+
* `403` if it does not. It is there so a caller can be explicit about who it believes it
|
|
806
|
+
* is, and be told when it is wrong; it can no longer be used to ask about somebody else.
|
|
807
|
+
*
|
|
808
|
+
* A duty-free party is not an error — it answers with an empty list.
|
|
809
|
+
*
|
|
810
|
+
* @throws {MissingAttestationError} If this client holds no attestation.
|
|
811
|
+
* @throws {ApiError} With `403` if `actor` names a party other than the one your
|
|
812
|
+
* attestation is bound to.
|
|
813
|
+
*/
|
|
814
|
+
async obligations(actor, options) {
|
|
815
|
+
return this.#send({
|
|
816
|
+
method: "GET",
|
|
817
|
+
path: "obligations",
|
|
818
|
+
...(actor === undefined ? {} : { query: { actor } }),
|
|
819
|
+
headers: this.#authorize("obligations"),
|
|
820
|
+
options,
|
|
821
|
+
});
|
|
822
|
+
}
|
|
823
|
+
/**
|
|
824
|
+
* An account's net balance, in minor units.
|
|
825
|
+
*
|
|
826
|
+
* The balance is a **`bigint`**: the ledger's minor units are 128-bit integers, and
|
|
827
|
+
* decoding one as a JavaScript `number` would truncate it silently. See `src/json.ts`.
|
|
828
|
+
*
|
|
829
|
+
* # Scope
|
|
830
|
+
*
|
|
831
|
+
* Requires an attestation **and is scoped to the account's owning party**: the service
|
|
832
|
+
* answers only when the attested party is the recorded owner of the account. An account
|
|
833
|
+
* owned by another party — or one opened with no owner at all — answers exactly like an
|
|
834
|
+
* account that does not exist (`404 unknown_account`), fail-closed and deliberately
|
|
835
|
+
* indistinguishable: an account id is never an existence oracle across a tenant boundary.
|
|
836
|
+
*
|
|
837
|
+
* @throws {MissingAttestationError} If this client holds no attestation.
|
|
838
|
+
* @throws {ApiError} With code `unknown_account` (404) if there is no such account *or*
|
|
839
|
+
* the attested party does not own it (the caller cannot tell which).
|
|
840
|
+
*/
|
|
841
|
+
async balance(accountId, options) {
|
|
842
|
+
return this.#send({
|
|
843
|
+
method: "GET",
|
|
844
|
+
path: `accounts/${encodeURIComponent(accountId)}/balance`,
|
|
845
|
+
headers: this.#authorize("a balance"),
|
|
846
|
+
options,
|
|
847
|
+
});
|
|
848
|
+
}
|
|
849
|
+
/**
|
|
850
|
+
* A usage balance's three running totals — **if your party owns it**.
|
|
851
|
+
*
|
|
852
|
+
* The report carries `consumed`, `metered` and `billed`, the two headrooms derived from
|
|
853
|
+
* them (`unmetered`, `unbilled`), whether the chain has `reconciled`, and `text` — the
|
|
854
|
+
* balance narrated in compliance English with exact figures. Prefer `text` when you have
|
|
855
|
+
* a human to show: it is the service's own sentence, not a rendering you assemble.
|
|
856
|
+
*
|
|
857
|
+
* # Every counter is a `bigint`
|
|
858
|
+
*
|
|
859
|
+
* More so here than anywhere else on the plane. `consumed` is the top of the chain and
|
|
860
|
+
* takes **no ceiling** — a busy meter can lawfully outgrow `u64` — so decoding a counter
|
|
861
|
+
* as a JavaScript `number` would eventually truncate a real figure silently. This client
|
|
862
|
+
* parses them as `bigint`s; see `src/json.ts`.
|
|
863
|
+
*
|
|
864
|
+
* # Scope
|
|
865
|
+
*
|
|
866
|
+
* Owner-scoped, and fail-closed. A balance owned by another party, one opened with no
|
|
867
|
+
* owner at all, and one that was never opened all answer the **same** `404 unknown_usage`
|
|
868
|
+
* — deliberately indistinguishable, so that a usage id is never an existence oracle
|
|
869
|
+
* across a tenant boundary. The refusal names no owner and does not disclose whether the
|
|
870
|
+
* balance exists; surface it as it stands rather than inferring more from it than it says.
|
|
871
|
+
*
|
|
872
|
+
* @throws {MissingAttestationError} If this client holds no attestation.
|
|
873
|
+
* @throws {ApiError} With code `unknown_usage` (404) if there is no such usage balance
|
|
874
|
+
* *or* the attested party does not own it (the caller cannot tell which), or
|
|
875
|
+
* `invalid_usage_id` (400) if that is not a usage-balance id.
|
|
876
|
+
*/
|
|
877
|
+
async usage(usageId, options) {
|
|
878
|
+
return this.#send({
|
|
879
|
+
method: "GET",
|
|
880
|
+
path: `usages/${encodeURIComponent(usageId)}`,
|
|
881
|
+
headers: this.#authorize("a usage balance"),
|
|
882
|
+
options,
|
|
883
|
+
});
|
|
884
|
+
}
|
|
885
|
+
/**
|
|
886
|
+
* The one funnel every call goes through: build, send, then decode.
|
|
887
|
+
*
|
|
888
|
+
* The whole of this client's wire handling lives here — which is the point. A success
|
|
889
|
+
* body decodes into `T`; a failure body decodes through {@link errorFromBody} into the
|
|
890
|
+
* class that says what to do about it, with its **extras intact**. A generic client
|
|
891
|
+
* would flatten those extras away, and without `pinned`/`active` no client can re-pin,
|
|
892
|
+
* and without `primary_api_addr` none can tell which replica holds the primary.
|
|
893
|
+
*
|
|
894
|
+
* It does **not** decide who the caller is. The `Authorization` header arrives already
|
|
895
|
+
* built, from {@link #authorize}, and only the party-scoped reads ask for it: a submit
|
|
896
|
+
* carries its attestation *inside* the envelope, and `policies/active` discloses no
|
|
897
|
+
* party's data and needs none. A funnel that credentialed everything by default is the
|
|
898
|
+
* mechanism this replaced.
|
|
899
|
+
*
|
|
900
|
+
* The body is read as **text** before being parsed, so that a body which fails to
|
|
901
|
+
* parse can be handed to a human verbatim. An unreadable failure is exactly the moment
|
|
902
|
+
* you need to see the actual bytes.
|
|
903
|
+
*
|
|
904
|
+
* On `authorize`'s lane, or for a read under a {@link AxorumClientOptions.retry | retry}
|
|
905
|
+
* policy, a send that failed in transit is re-sent with the same bytes and the same
|
|
906
|
+
* `x-request-id`, to the same endpoint or the next, exactly as `acton-service-client`
|
|
907
|
+
* 0.2.1 does for the Rust SDK; `retry.ts` and `failover.ts` hold the rules. When the
|
|
908
|
+
* budget stops the loop, the last answer or failure is what the call ends on, or an
|
|
909
|
+
* {@link EndpointsExhaustedError} carrying every attempt once the call has moved
|
|
910
|
+
* endpoints; a request the deadline left no time to send is a
|
|
911
|
+
* {@link DeadlineExceededError}.
|
|
912
|
+
*/
|
|
913
|
+
async #send(request) {
|
|
914
|
+
const url = this.#url(request.path, request.query, request.root === true);
|
|
915
|
+
const headers = {
|
|
916
|
+
accept: "application/json",
|
|
917
|
+
// Always present, generated when not supplied: a request you cannot correlate with
|
|
918
|
+
// a log line is a request you cannot debug. The same id rides every re-send.
|
|
919
|
+
"x-request-id": request.options?.requestId ?? crypto.randomUUID(),
|
|
920
|
+
...request.headers,
|
|
921
|
+
};
|
|
922
|
+
if (request.body !== undefined) {
|
|
923
|
+
headers["content-type"] = "application/json";
|
|
924
|
+
}
|
|
925
|
+
// `stringifyJson`, not `JSON.stringify`: the envelope carries 128-bit money as
|
|
926
|
+
// `bigint`, which `JSON.stringify` refuses outright.
|
|
927
|
+
const body = request.body === undefined ? undefined : stringifyJson(request.body);
|
|
928
|
+
const caller = request.options?.signal;
|
|
929
|
+
const { bound, lane } = request;
|
|
930
|
+
const policy = sendPolicy(request.method, lane, this.#retry);
|
|
931
|
+
// A submission on the lane re-sends `421` too; its reads, being reads, need not.
|
|
932
|
+
const retryOn = lane !== undefined && request.method === "POST" ? AUTHORIZE_RESENT_STATUSES : [];
|
|
933
|
+
const attemptMs = lane === undefined ? request.attemptMs : (request.attemptMs ?? SUBMIT_ATTEMPT_FALLBACK_MS);
|
|
934
|
+
const started = performance.now();
|
|
935
|
+
// Whichever comes first: the call's, the submission's own, the policy's.
|
|
936
|
+
const deadline = earliest(earliest(lane?.deadline, bound?.deadline), policy?.deadlineMs === undefined ? undefined : started + policy.deadlineMs);
|
|
937
|
+
const remaining = () => deadline === undefined ? undefined : Math.max(0, deadline - performance.now());
|
|
938
|
+
const failover = new Failover(this.#endpoints.length, this.#preferred.value);
|
|
939
|
+
// The previous attempt's failure, ended on if the budget runs out before the next,
|
|
940
|
+
// and whether its status was a documented answer.
|
|
941
|
+
let last;
|
|
942
|
+
// `listed`: the loop gave up on `final` while it still asked for another try, so the
|
|
943
|
+
// trace ends with it. On `authorize`'s lane such an answer is returned with every
|
|
944
|
+
// attempt before it, as the Rust SDK reads `AttemptTrace` off the response, so
|
|
945
|
+
// `authorize` can tell a call that reached nothing able to apply it from one that
|
|
946
|
+
// may have committed. The caller's lane keeps the plain error.
|
|
947
|
+
const finish = (stop, final, listed) => {
|
|
948
|
+
const elapsedMs = performance.now() - started;
|
|
949
|
+
if (final === undefined) {
|
|
950
|
+
throw new DeadlineExceededError(failover.attempts, elapsedMs);
|
|
951
|
+
}
|
|
952
|
+
if (stop === "exhausted" && !final.accepted) {
|
|
953
|
+
throw new EndpointsExhaustedError(failover.trace(this.#endpoints), final.error, elapsedMs);
|
|
954
|
+
}
|
|
955
|
+
if (listed && final.accepted && lane !== undefined) {
|
|
956
|
+
throw new EndpointsExhaustedError(failover.trace(this.#endpoints), final.error, elapsedMs);
|
|
957
|
+
}
|
|
958
|
+
throw final.error;
|
|
959
|
+
};
|
|
960
|
+
// The endpoint of the attempt sent last.
|
|
961
|
+
let sentTo;
|
|
962
|
+
const ended = () => {
|
|
963
|
+
const at = sentTo === undefined ? undefined : this.#endpoints[sentTo];
|
|
964
|
+
if (at !== undefined) {
|
|
965
|
+
request.onEnded?.(at);
|
|
966
|
+
}
|
|
967
|
+
};
|
|
968
|
+
for (;;) {
|
|
969
|
+
const step = failover.begin(remaining());
|
|
970
|
+
if ("stop" in step) {
|
|
971
|
+
ended();
|
|
972
|
+
return finish(step.stop, last, false);
|
|
973
|
+
}
|
|
974
|
+
// A re-send is heard as it goes out, so a pause that reaches the deadline ends the
|
|
975
|
+
// loop unheard, as acton's does.
|
|
976
|
+
if (step.resend !== undefined) {
|
|
977
|
+
const { resend } = step;
|
|
978
|
+
const at = this.#endpoints[resend.kind === "rotation" ? resend.from : step.endpoint];
|
|
979
|
+
if (at !== undefined) {
|
|
980
|
+
bound?.onResend({ endpoint: at, outcome: resend.reason, rotated: resend.kind === "rotation" });
|
|
981
|
+
}
|
|
982
|
+
this.#observe(resend, step.endpoint, step.number);
|
|
983
|
+
}
|
|
984
|
+
sentTo = step.endpoint;
|
|
985
|
+
const timeoutMs = attemptTimeout(undefined, attemptMs, this.#timeoutMs, remaining()) ?? this.#timeoutMs;
|
|
986
|
+
const target = this.#endpoints.length === 1 ? url : onEndpoint(url, this.#endpoints[step.endpoint] ?? url);
|
|
987
|
+
const exchanged = await this.#exchange(target, request.method, headers, body, caller, timeoutMs);
|
|
988
|
+
let outcome;
|
|
989
|
+
let failed;
|
|
990
|
+
if ("failure" in exchanged) {
|
|
991
|
+
outcome = outcomeOfTransport(exchanged.connect, exchanged.timeout);
|
|
992
|
+
failed = { error: exchanged.failure, accepted: false };
|
|
993
|
+
}
|
|
994
|
+
else {
|
|
995
|
+
const { status, text } = exchanged;
|
|
996
|
+
if (status >= 200 && status < 300) {
|
|
997
|
+
request.onHeaders?.(exchanged.headers, status);
|
|
998
|
+
this.#preferred.value = step.endpoint;
|
|
999
|
+
if (request.headersOnly === true) {
|
|
1000
|
+
return undefined;
|
|
1001
|
+
}
|
|
1002
|
+
try {
|
|
1003
|
+
return parseJson(text);
|
|
1004
|
+
}
|
|
1005
|
+
catch (error) {
|
|
1006
|
+
throw new DecodeError(status, describe(error), text);
|
|
1007
|
+
}
|
|
1008
|
+
}
|
|
1009
|
+
const accepted = CONTRACT_STATUSES.has(status);
|
|
1010
|
+
outcome = outcomeOfStatus(status, exchanged.retryAfterMs, accepted, retryOn);
|
|
1011
|
+
if (outcome.kind !== "rotate") {
|
|
1012
|
+
this.#preferred.value = step.endpoint;
|
|
1013
|
+
}
|
|
1014
|
+
failed = { error: failure(status, text, exchanged.retryAfterMs ?? null), accepted };
|
|
1015
|
+
}
|
|
1016
|
+
const next = failover.after(outcome, policy, Math.random, remaining());
|
|
1017
|
+
if ("stop" in next) {
|
|
1018
|
+
ended();
|
|
1019
|
+
return finish(next.stop, failed, outcome.kind !== "final");
|
|
1020
|
+
}
|
|
1021
|
+
last = failed;
|
|
1022
|
+
if (next.kind === "after") {
|
|
1023
|
+
await pause(next.pauseMs, caller);
|
|
1024
|
+
}
|
|
1025
|
+
}
|
|
1026
|
+
}
|
|
1027
|
+
/**
|
|
1028
|
+
* Tells {@link AxorumClientOptions.onRotation} or {@link AxorumClientOptions.onRetry}
|
|
1029
|
+
* about a re-send as it goes out, if either listens. What they throw is ignored: an
|
|
1030
|
+
* observer's failure is not the call's.
|
|
1031
|
+
*/
|
|
1032
|
+
#observe(resend, endpoint, attempt) {
|
|
1033
|
+
try {
|
|
1034
|
+
if (resend.kind === "rotation") {
|
|
1035
|
+
const left = this.#endpoints[resend.from];
|
|
1036
|
+
if (left !== undefined) {
|
|
1037
|
+
this.#onRotation?.(left, resend.reason);
|
|
1038
|
+
}
|
|
1039
|
+
}
|
|
1040
|
+
else {
|
|
1041
|
+
const at = this.#endpoints[endpoint];
|
|
1042
|
+
if (at !== undefined) {
|
|
1043
|
+
this.#onRetry?.(at, resend.reason, attempt);
|
|
1044
|
+
}
|
|
1045
|
+
}
|
|
1046
|
+
}
|
|
1047
|
+
catch {
|
|
1048
|
+
// Documented as ignored.
|
|
1049
|
+
}
|
|
1050
|
+
}
|
|
1051
|
+
/**
|
|
1052
|
+
* One send, bounded by `timeoutMs`: the status and body text, or the transport failure
|
|
1053
|
+
* and how it ended (a connection that never opened, or the timeout).
|
|
1054
|
+
*/
|
|
1055
|
+
async #exchange(url, method, headers, body, caller, timeoutMs) {
|
|
1056
|
+
// Whole milliseconds: a timer takes no fraction, and rounding up never cuts an
|
|
1057
|
+
// attempt short of its budget.
|
|
1058
|
+
const timeout = AbortSignal.timeout(Math.ceil(timeoutMs));
|
|
1059
|
+
const signal = caller === undefined ? timeout : AbortSignal.any([caller, timeout]);
|
|
1060
|
+
let response;
|
|
1061
|
+
try {
|
|
1062
|
+
response = await this.#fetch(url, {
|
|
1063
|
+
method,
|
|
1064
|
+
headers,
|
|
1065
|
+
...(body === undefined ? {} : { body }),
|
|
1066
|
+
// A redirect could lead out of the endpoint set; none is followed with more
|
|
1067
|
+
// than one endpoint, and the 3xx ends the call.
|
|
1068
|
+
...(this.#endpoints.length > 1 ? { redirect: "manual" } : {}),
|
|
1069
|
+
signal,
|
|
1070
|
+
});
|
|
1071
|
+
}
|
|
1072
|
+
catch (error) {
|
|
1073
|
+
// A cancellation the caller asked for is not a failure of the ledger, and is not
|
|
1074
|
+
// dressed up as one — it is rethrown exactly as it was raised.
|
|
1075
|
+
if (caller?.aborted === true) {
|
|
1076
|
+
throw error;
|
|
1077
|
+
}
|
|
1078
|
+
if (!timeout.aborted && isConnectFailure(error)) {
|
|
1079
|
+
return {
|
|
1080
|
+
failure: new ConnectError(`no connection to the Axorum ledger service could be opened, so nothing was sent: ${describe(error)}`, error),
|
|
1081
|
+
connect: true,
|
|
1082
|
+
timeout: false,
|
|
1083
|
+
};
|
|
1084
|
+
}
|
|
1085
|
+
return {
|
|
1086
|
+
failure: new TransportError(`the Axorum ledger service could not be reached: ${describe(error)}`, error),
|
|
1087
|
+
connect: false,
|
|
1088
|
+
timeout: timeout.aborted,
|
|
1089
|
+
};
|
|
1090
|
+
}
|
|
1091
|
+
const status = response.status;
|
|
1092
|
+
try {
|
|
1093
|
+
const text = await response.text();
|
|
1094
|
+
return {
|
|
1095
|
+
status,
|
|
1096
|
+
text,
|
|
1097
|
+
headers: response.headers,
|
|
1098
|
+
retryAfterMs: parseRetryAfter(response.headers.get("retry-after")),
|
|
1099
|
+
};
|
|
1100
|
+
}
|
|
1101
|
+
catch (error) {
|
|
1102
|
+
if (caller?.aborted === true) {
|
|
1103
|
+
throw error;
|
|
1104
|
+
}
|
|
1105
|
+
return {
|
|
1106
|
+
failure: new TransportError(`the Axorum ledger service's ${status} response body could not be read: ${describe(error)}`, error),
|
|
1107
|
+
connect: false,
|
|
1108
|
+
timeout: false,
|
|
1109
|
+
};
|
|
1110
|
+
}
|
|
1111
|
+
}
|
|
1112
|
+
/**
|
|
1113
|
+
* Builds the absolute URL for a route: `{baseUrl}/api/v1/{path}[?query]`, or
|
|
1114
|
+
* `{baseUrl}/{path}` for a route at the application root.
|
|
1115
|
+
*/
|
|
1116
|
+
#url(path, query, root = false) {
|
|
1117
|
+
const url = new URL(root ? `${this.#baseUrl}/${path}` : `${this.#baseUrl}${BASE_PATH}/${path}`);
|
|
1118
|
+
if (query !== undefined) {
|
|
1119
|
+
for (const [key, value] of Object.entries(query)) {
|
|
1120
|
+
url.searchParams.set(key, value);
|
|
1121
|
+
}
|
|
1122
|
+
}
|
|
1123
|
+
return url.toString();
|
|
1124
|
+
}
|
|
1125
|
+
}
|
|
1126
|
+
/**
|
|
1127
|
+
* The error a non-2xx answer raises: the documented class its agent-plane error body
|
|
1128
|
+
* names, {@link RateLimitedError} for the service's own limiter's `429`, or
|
|
1129
|
+
* {@link UnexpectedResponseError} carrying the bytes verbatim when the body is neither
|
|
1130
|
+
* (a proxy, a gateway, a load balancer).
|
|
1131
|
+
*/
|
|
1132
|
+
function failure(status, text, retryAfterMs) {
|
|
1133
|
+
let body;
|
|
1134
|
+
try {
|
|
1135
|
+
body = parseJson(text);
|
|
1136
|
+
}
|
|
1137
|
+
catch {
|
|
1138
|
+
return new UnexpectedResponseError(status, text);
|
|
1139
|
+
}
|
|
1140
|
+
const limited = limiterMessage(status, body);
|
|
1141
|
+
if (limited !== undefined) {
|
|
1142
|
+
return new RateLimitedError(limited);
|
|
1143
|
+
}
|
|
1144
|
+
if (!isErrorBody(body)) {
|
|
1145
|
+
return new UnexpectedResponseError(status, text);
|
|
1146
|
+
}
|
|
1147
|
+
return errorFromBody(status, body, retryAfterMs);
|
|
1148
|
+
}
|
|
1149
|
+
/**
|
|
1150
|
+
* The limiter's `error` text when `body` is the `429` the service's own rate limiter
|
|
1151
|
+
* answers with (`code: "RATE_LIMIT_EXCEEDED"`), or `undefined`. Pure.
|
|
1152
|
+
*/
|
|
1153
|
+
function limiterMessage(status, body) {
|
|
1154
|
+
if (status !== 429 || typeof body !== "object" || body === null) {
|
|
1155
|
+
return undefined;
|
|
1156
|
+
}
|
|
1157
|
+
const { code, error } = body;
|
|
1158
|
+
if (code !== RATE_LIMIT_EXCEEDED) {
|
|
1159
|
+
return undefined;
|
|
1160
|
+
}
|
|
1161
|
+
return typeof error === "string" ? error : "";
|
|
1162
|
+
}
|
|
1163
|
+
/**
|
|
1164
|
+
* The local checks {@link AxorumClient.submitKeyed} makes before anything is sent: the
|
|
1165
|
+
* envelope carries the key's id, and the window still covers the key.
|
|
1166
|
+
*
|
|
1167
|
+
* @internal Exported for tests.
|
|
1168
|
+
* @throws {ConfigError | StaleIdempotencyKeyError}
|
|
1169
|
+
*/
|
|
1170
|
+
export function checkKeyed(key, window, envelope, nowMs) {
|
|
1171
|
+
if (envelope.transaction !== key.transaction) {
|
|
1172
|
+
throw new ConfigError(`the envelope's transaction '${envelope.transaction}' is not the key's '${key.transaction}': ` +
|
|
1173
|
+
`a key vouches only for its own id. Build the envelope with transaction: key.transaction.`);
|
|
1174
|
+
}
|
|
1175
|
+
ensureFresh(key, window, nowMs);
|
|
1176
|
+
}
|
|
1177
|
+
/**
|
|
1178
|
+
* Refuses an envelope that carries no mint time, before it is sent: `minted_at_ms` must
|
|
1179
|
+
* be a non-negative `bigint` of milliseconds since the Unix epoch, as the wire type says. The type
|
|
1180
|
+
* requires it; this holds a caller the type cannot reach (plain JavaScript, a cast).
|
|
1181
|
+
* Pure.
|
|
1182
|
+
*
|
|
1183
|
+
* @internal Exported for tests.
|
|
1184
|
+
* @throws {ConfigError}
|
|
1185
|
+
*/
|
|
1186
|
+
export function checkMinted(envelope) {
|
|
1187
|
+
const minted = envelope.minted_at_ms;
|
|
1188
|
+
if (typeof minted !== "bigint" || minted < 0n) {
|
|
1189
|
+
throw new ConfigError(`the envelope for transaction '${envelope.transaction}' carries no minted_at_ms: ` +
|
|
1190
|
+
`build it with pinIntent, or set the time its transaction id was first issued`);
|
|
1191
|
+
}
|
|
1192
|
+
}
|
|
1193
|
+
/**
|
|
1194
|
+
* Validates the base URL and strips any trailing slash, so that joining `/api/v1` to it
|
|
1195
|
+
* never produces a double slash.
|
|
1196
|
+
*
|
|
1197
|
+
* @throws {ConfigError} If it is not an absolute `http`/`https` URL.
|
|
1198
|
+
*/
|
|
1199
|
+
function normalizeBaseUrl(baseUrl) {
|
|
1200
|
+
let parsed;
|
|
1201
|
+
try {
|
|
1202
|
+
parsed = new URL(baseUrl);
|
|
1203
|
+
}
|
|
1204
|
+
catch {
|
|
1205
|
+
throw new ConfigError(`the Axorum client could not be built: '${baseUrl}' is not a URL`);
|
|
1206
|
+
}
|
|
1207
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
|
1208
|
+
throw new ConfigError(`the Axorum client could not be built: '${baseUrl}' is not an http or https URL`);
|
|
1209
|
+
}
|
|
1210
|
+
return baseUrl.replace(/\/+$/, "");
|
|
1211
|
+
}
|
|
1212
|
+
/** The best available prose for a thrown value, without assuming it is an `Error`. */
|
|
1213
|
+
function describe(error) {
|
|
1214
|
+
if (error instanceof Error) {
|
|
1215
|
+
return error.message === "" ? error.name : error.message;
|
|
1216
|
+
}
|
|
1217
|
+
return String(error);
|
|
1218
|
+
}
|
|
1219
|
+
/**
|
|
1220
|
+
* Why a submission attempt waits {@link SUBMIT_ATTEMPT_FALLBACK_MS}: the service
|
|
1221
|
+
* advertises no commit wait (`failure` absent), or reading it failed with `failure`.
|
|
1222
|
+
* Pure. Internal: exported for this package's tests, never from its index.
|
|
1223
|
+
*/
|
|
1224
|
+
export function fallbackNote(failure) {
|
|
1225
|
+
return failure === undefined
|
|
1226
|
+
? `the service advertises no ${SUBMIT_TIMEOUT_HEADER}; each submission attempt waits the ` +
|
|
1227
|
+
`${SUBMIT_ATTEMPT_FALLBACK_MS} ms fallback`
|
|
1228
|
+
: `reading the service's commit wait failed (${describe(failure)}); this submission attempt ` +
|
|
1229
|
+
`waits the ${SUBMIT_ATTEMPT_FALLBACK_MS} ms fallback`;
|
|
1230
|
+
}
|
|
1231
|
+
/** The earlier of two deadlines, either of which may be absent. Pure. */
|
|
1232
|
+
export function earliest(a, b) {
|
|
1233
|
+
return a === undefined ? b : b === undefined ? a : Math.min(a, b);
|
|
1234
|
+
}
|
|
1235
|
+
/**
|
|
1236
|
+
* The retry policy a request is sent under, or `undefined` to send it once. Pure.
|
|
1237
|
+
*
|
|
1238
|
+
* `authorize`'s lane re-sends every request of the call, its `POST /intents` included:
|
|
1239
|
+
* the service answers a resubmitted transaction id with the stored outcome, never
|
|
1240
|
+
* applying it twice (`POST /intents`, crates/axorum-serve/src/routes/agent.rs), so a
|
|
1241
|
+
* re-send to the same endpoint or the next is safe. Off the lane only reads are
|
|
1242
|
+
* re-sent: a caller's own envelope is sent once, to one endpoint.
|
|
1243
|
+
*/
|
|
1244
|
+
export function sendPolicy(method, lane, retry) {
|
|
1245
|
+
if (lane !== undefined) {
|
|
1246
|
+
return lane.retries ? AUTHORIZE_RETRY_POLICY : undefined;
|
|
1247
|
+
}
|
|
1248
|
+
return method === "GET" ? retry : undefined;
|
|
1249
|
+
}
|
|
1250
|
+
/** What a client built with one endpoint logs, once per process. */
|
|
1251
|
+
export const ONE_ENDPOINT_WARNING = "one endpoint configured: an LB routing to the primary is assumed";
|
|
1252
|
+
/** Whether {@link ONE_ENDPOINT_WARNING} has been emitted in this process. */
|
|
1253
|
+
const oneEndpoint = { warned: false };
|
|
1254
|
+
/** Emits {@link ONE_ENDPOINT_WARNING} for the first client built with one endpoint. */
|
|
1255
|
+
function warnOneEndpoint(baseUrl) {
|
|
1256
|
+
warnOnce(oneEndpoint, emitWarning, baseUrl);
|
|
1257
|
+
}
|
|
1258
|
+
/**
|
|
1259
|
+
* Emits {@link ONE_ENDPOINT_WARNING}, naming `baseUrl`, unless `state` says it already
|
|
1260
|
+
* was. The testable seam of {@link warnOneEndpoint}.
|
|
1261
|
+
*/
|
|
1262
|
+
export function warnOnce(state, emit, baseUrl) {
|
|
1263
|
+
if (state.warned) {
|
|
1264
|
+
return;
|
|
1265
|
+
}
|
|
1266
|
+
state.warned = true;
|
|
1267
|
+
emit(`${ONE_ENDPOINT_WARNING} (${baseUrl}); name every replica in failoverUrls otherwise`);
|
|
1268
|
+
}
|
|
1269
|
+
/**
|
|
1270
|
+
* A process warning where the runtime has them (Node's `process.emitWarning`, which
|
|
1271
|
+
* `--no-warnings` silences and `process.on("warning")` observes), `console.warn`
|
|
1272
|
+
* elsewhere.
|
|
1273
|
+
*/
|
|
1274
|
+
function emitWarning(message) {
|
|
1275
|
+
const process = globalThis
|
|
1276
|
+
.process;
|
|
1277
|
+
if (typeof process?.emitWarning === "function") {
|
|
1278
|
+
process.emitWarning(message, { type: "AxorumWarning", code: "AXORUM_ONE_ENDPOINT" });
|
|
1279
|
+
}
|
|
1280
|
+
else {
|
|
1281
|
+
console.warn(message);
|
|
1282
|
+
}
|
|
1283
|
+
}
|
|
1284
|
+
/** Waits `ms` before a re-send, or rejects with the caller's reason if it cancels first. */
|
|
1285
|
+
async function pause(ms, caller) {
|
|
1286
|
+
caller?.throwIfAborted();
|
|
1287
|
+
await new Promise((resolve, reject) => {
|
|
1288
|
+
const onAbort = () => {
|
|
1289
|
+
clearTimeout(timer);
|
|
1290
|
+
reject(caller?.reason);
|
|
1291
|
+
};
|
|
1292
|
+
const timer = setTimeout(() => {
|
|
1293
|
+
caller?.removeEventListener("abort", onAbort);
|
|
1294
|
+
resolve();
|
|
1295
|
+
}, ms);
|
|
1296
|
+
caller?.addEventListener("abort", onAbort, { once: true });
|
|
1297
|
+
});
|
|
1298
|
+
}
|
|
1299
|
+
/** Whether `ms` is a budget `authorizeDeadlineMs` accepts: whole, positive, at most 15 000. Pure. */
|
|
1300
|
+
export function validAuthorizeDeadline(ms) {
|
|
1301
|
+
return Number.isSafeInteger(ms) && ms > 0 && ms <= AUTHORIZE_DEADLINE_MS;
|
|
1302
|
+
}
|
|
1303
|
+
//# sourceMappingURL=client.js.map
|