@aetherwealth/sdk 0.1.32
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 +21 -0
- package/README.md +207 -0
- package/dist/client.d.ts +130 -0
- package/dist/client.js +429 -0
- package/dist/errors.d.ts +131 -0
- package/dist/errors.js +178 -0
- package/dist/index.d.ts +27 -0
- package/dist/index.js +24 -0
- package/dist/resources/accounts.d.ts +8 -0
- package/dist/resources/accounts.js +84 -0
- package/dist/resources/alerts.d.ts +8 -0
- package/dist/resources/alerts.js +178 -0
- package/dist/resources/diary.d.ts +8 -0
- package/dist/resources/diary.js +75 -0
- package/dist/resources/envelope.d.ts +7 -0
- package/dist/resources/envelope.js +15 -0
- package/dist/resources/idempotency.d.ts +15 -0
- package/dist/resources/idempotency.js +19 -0
- package/dist/resources/market.d.ts +8 -0
- package/dist/resources/market.js +90 -0
- package/dist/resources/paginate.d.ts +15 -0
- package/dist/resources/paginate.js +41 -0
- package/dist/resources/stats.d.ts +8 -0
- package/dist/resources/stats.js +28 -0
- package/dist/resources/trades.d.ts +8 -0
- package/dist/resources/trades.js +113 -0
- package/dist/resources/validate.d.ts +12 -0
- package/dist/resources/validate.js +22 -0
- package/dist/retry.d.ts +40 -0
- package/dist/retry.js +110 -0
- package/dist/schemas.d.ts +286 -0
- package/dist/schemas.js +239 -0
- package/dist/types.d.ts +528 -0
- package/dist/types.js +12 -0
- package/package.json +55 -0
package/dist/client.js
ADDED
|
@@ -0,0 +1,429 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AetherClient — typed HTTP client for the AetherWealth **public API**
|
|
3
|
+
* (`/api/public/v1/<domain>/<action>`, POST-RPC, `{success, <key>}` envelopes).
|
|
4
|
+
*
|
|
5
|
+
* Auth: every public route is authenticated with a public API key sent as
|
|
6
|
+
* `Authorization: Bearer aw_live_…`. Configure it via
|
|
7
|
+
* `auth: {type: 'apiKey', apiKey}`. `auth` is a discriminated union so an
|
|
8
|
+
* `oauth`/`hmac` member can be added later without breaking existing callers.
|
|
9
|
+
*
|
|
10
|
+
* The HTTP transport is pluggable via `fetchImpl` so tests assert wire shape
|
|
11
|
+
* without touching the network.
|
|
12
|
+
*/
|
|
13
|
+
import { AetherApiError, AetherNetworkError, AetherTimeoutError, classifyError } from './errors.js';
|
|
14
|
+
import { createAccountsResource } from './resources/accounts.js';
|
|
15
|
+
import { createAlertsResource } from './resources/alerts.js';
|
|
16
|
+
import { createDiaryResource } from './resources/diary.js';
|
|
17
|
+
import { createMarketResource } from './resources/market.js';
|
|
18
|
+
import { createStatsResource } from './resources/stats.js';
|
|
19
|
+
import { createTradesResource } from './resources/trades.js';
|
|
20
|
+
/** Loopback hosts are TLS-exempt (local dev backend). Mirrors client-core. */
|
|
21
|
+
const LOOPBACK_HOSTS = new Set(['localhost', '127.0.0.1', '[::1]']);
|
|
22
|
+
/** Default per-request timeout budget (ms). */
|
|
23
|
+
export const DEFAULT_TIMEOUT_MS = 30_000;
|
|
24
|
+
/** Production public API base URL — used when `baseUrl` is omitted. */
|
|
25
|
+
export const DEFAULT_BASE_URL = 'https://api.aetherwealth.ai';
|
|
26
|
+
/**
|
|
27
|
+
* True when running in a browser-like environment. Reads through `globalThis`
|
|
28
|
+
* (typed, always defined) rather than a bare `window` reference so it also
|
|
29
|
+
* type-checks in DOM-less Node/Bun/edge type setups. `window.document` is the
|
|
30
|
+
* discriminator — a Node global named `window` (rare) won't have `document`.
|
|
31
|
+
*/
|
|
32
|
+
function isBrowserLike() {
|
|
33
|
+
const g = globalThis;
|
|
34
|
+
return typeof g.window !== 'undefined' && typeof g.window.document !== 'undefined';
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Refuse to construct in a browser unless explicitly allowed. The API key is a
|
|
38
|
+
* long-lived server secret; a key shipped to a browser is visible in the bundle
|
|
39
|
+
* and DevTools and must be treated as compromised. Mirrors the OpenAI SDK's
|
|
40
|
+
* `dangerouslyAllowBrowser` escape hatch.
|
|
41
|
+
*/
|
|
42
|
+
function assertNotBrowserUnlessAllowed(dangerouslyAllowBrowser) {
|
|
43
|
+
if (dangerouslyAllowBrowser || !isBrowserLike())
|
|
44
|
+
return;
|
|
45
|
+
throw new Error('AetherClient: refusing to run in a browser-like environment. The API key ' +
|
|
46
|
+
'(aw_live_…) is a server-side secret — exposing it to a browser leaks it to ' +
|
|
47
|
+
'every end user (bundle + DevTools). Call the API from your server instead. ' +
|
|
48
|
+
'If you are certain this is a trusted non-browser runtime, set ' +
|
|
49
|
+
'dangerouslyAllowBrowser: true.');
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Transport-security guard. This SDK attaches a long-lived API key to every
|
|
53
|
+
* request, so the transport MUST be https unless the host is loopback (local
|
|
54
|
+
* dev). Asserted at construction — rather than trusting each consumer to check
|
|
55
|
+
* — so a misconfigured `http://` base URL can't silently leak the key in
|
|
56
|
+
* cleartext. Mirrors client-core's `assertHttpsUnlessLoopback`.
|
|
57
|
+
*/
|
|
58
|
+
function assertHttpsUnlessLoopback(baseUrl) {
|
|
59
|
+
let url;
|
|
60
|
+
try {
|
|
61
|
+
url = new URL(baseUrl);
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
throw new Error(`AetherClient: invalid baseUrl: "${baseUrl}"`);
|
|
65
|
+
}
|
|
66
|
+
// A scheme-less input like "localhost:9006" parses with protocol "localhost:";
|
|
67
|
+
// catch that here so the error names the real problem (missing scheme).
|
|
68
|
+
if (url.protocol !== 'https:' && url.protocol !== 'http:') {
|
|
69
|
+
throw new Error(`AetherClient: baseUrl must start with https:// (or http:// for loopback): "${baseUrl}"`);
|
|
70
|
+
}
|
|
71
|
+
if (url.protocol !== 'https:' && !LOOPBACK_HOSTS.has(url.hostname)) {
|
|
72
|
+
throw new Error(`AetherClient: baseUrl must use https (loopback hosts exempt): "${baseUrl}"`);
|
|
73
|
+
}
|
|
74
|
+
// Request URLs are built as `baseUrl + path`, so a query/fragment/credentials
|
|
75
|
+
// in the base would corrupt every path (and could smuggle a secret into the
|
|
76
|
+
// query string). Reject them up front.
|
|
77
|
+
if (url.search || url.hash || url.username || url.password) {
|
|
78
|
+
throw new Error(`AetherClient: baseUrl must not contain a query, fragment, or credentials: "${baseUrl}"`);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
/** Append one query entry, skipping null/undefined and repeating array values. */
|
|
82
|
+
function appendQueryParam(params, key, value) {
|
|
83
|
+
if (value === undefined || value === null)
|
|
84
|
+
return;
|
|
85
|
+
if (Array.isArray(value)) {
|
|
86
|
+
for (const item of value) {
|
|
87
|
+
if (item !== undefined && item !== null)
|
|
88
|
+
params.append(key, String(item));
|
|
89
|
+
}
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
params.set(key, String(value));
|
|
93
|
+
}
|
|
94
|
+
function buildQueryString(query) {
|
|
95
|
+
if (!query)
|
|
96
|
+
return '';
|
|
97
|
+
const params = new URLSearchParams();
|
|
98
|
+
for (const [key, value] of Object.entries(query)) {
|
|
99
|
+
appendQueryParam(params, key, value);
|
|
100
|
+
}
|
|
101
|
+
const s = params.toString();
|
|
102
|
+
return s ? `?${s}` : '';
|
|
103
|
+
}
|
|
104
|
+
/** Sentinel that replaces the API key wherever it is scrubbed from a body. */
|
|
105
|
+
const REDACTED = '[REDACTED]';
|
|
106
|
+
/**
|
|
107
|
+
* Recursively replace every occurrence of the API key — both raw and as a
|
|
108
|
+
* `Bearer <key>` token — with {@link REDACTED}, returning a NEW structure so the
|
|
109
|
+
* caller's parsed body is never mutated. Used to scrub an error body BEFORE it
|
|
110
|
+
* is copied onto a thrown error: a proxy/WAF that echoes the `Authorization`
|
|
111
|
+
* header into a 4xx JSON body must not leak the key via `err.message` /
|
|
112
|
+
* `err.responseBody`. A blank secret is a no-op (nothing to redact).
|
|
113
|
+
*/
|
|
114
|
+
function redactSecret(value, secret) {
|
|
115
|
+
if (secret.length === 0)
|
|
116
|
+
return value;
|
|
117
|
+
if (typeof value === 'string') {
|
|
118
|
+
// `Bearer <key>` first so the whole token collapses to one sentinel,
|
|
119
|
+
// then any standalone raw key. `replaceAll` with a string is literal
|
|
120
|
+
// (no regex), so a key with special chars is matched verbatim.
|
|
121
|
+
return value.replaceAll(`Bearer ${secret}`, REDACTED).replaceAll(secret, REDACTED);
|
|
122
|
+
}
|
|
123
|
+
if (Array.isArray(value)) {
|
|
124
|
+
return value.map(item => redactSecret(item, secret));
|
|
125
|
+
}
|
|
126
|
+
if (value !== null && typeof value === 'object') {
|
|
127
|
+
const redacted = {};
|
|
128
|
+
for (const [key, item] of Object.entries(value)) {
|
|
129
|
+
// Redact the key name too — a body echoing the key AS a property
|
|
130
|
+
// name is pathological but cheap to close.
|
|
131
|
+
redacted[redactSecret(key, secret)] = redactSecret(item, secret);
|
|
132
|
+
}
|
|
133
|
+
return redacted;
|
|
134
|
+
}
|
|
135
|
+
return value;
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* Redact the API key from a thrown `cause` before it is attached to an
|
|
139
|
+
* `AetherNetworkError`/`AetherTimeoutError`. `redactSecret` can't handle an
|
|
140
|
+
* `Error` (its `message`/`stack` are non-enumerable), so clone it — preserve
|
|
141
|
+
* `name`, redact `message` + `stack`. Non-`Error` causes go through
|
|
142
|
+
* `redactSecret`.
|
|
143
|
+
*/
|
|
144
|
+
function redactCause(cause, secret) {
|
|
145
|
+
if (cause instanceof Error) {
|
|
146
|
+
const clone = new Error(redactSecret(cause.message, secret));
|
|
147
|
+
clone.name = cause.name;
|
|
148
|
+
if (cause.stack)
|
|
149
|
+
clone.stack = redactSecret(cause.stack, secret);
|
|
150
|
+
return clone;
|
|
151
|
+
}
|
|
152
|
+
return redactSecret(cause, secret);
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* Compose an internal timeout with an optional caller-supplied `AbortSignal`
|
|
156
|
+
* into one signal for `fetch`: the request aborts when EITHER fires. `timedOut`
|
|
157
|
+
* distinguishes the two afterwards so the caller can throw the right error.
|
|
158
|
+
*
|
|
159
|
+
* A `timeoutMs` of 0 (or non-finite) disables the internal timeout — only the
|
|
160
|
+
* caller's signal (if any) can then abort.
|
|
161
|
+
*/
|
|
162
|
+
function armAbort(callerSignal, timeoutMs) {
|
|
163
|
+
const controller = new AbortController();
|
|
164
|
+
let didTimeout = false;
|
|
165
|
+
let didCallerAbort = false;
|
|
166
|
+
let timer;
|
|
167
|
+
const clearInternalTimer = () => {
|
|
168
|
+
if (timer !== undefined) {
|
|
169
|
+
clearTimeout(timer);
|
|
170
|
+
timer = undefined;
|
|
171
|
+
}
|
|
172
|
+
};
|
|
173
|
+
let onCallerAbort;
|
|
174
|
+
if (callerSignal) {
|
|
175
|
+
if (callerSignal.aborted) {
|
|
176
|
+
// Already aborted at construction — record it now so no later timer
|
|
177
|
+
// can flip this into a (wrong) timeout classification.
|
|
178
|
+
didCallerAbort = true;
|
|
179
|
+
controller.abort(callerSignal.reason);
|
|
180
|
+
}
|
|
181
|
+
else {
|
|
182
|
+
onCallerAbort = () => {
|
|
183
|
+
didCallerAbort = true;
|
|
184
|
+
// Kill the internal timer so a caller abort that races it can
|
|
185
|
+
// never be read back as `timedOut()` in the catch classifier.
|
|
186
|
+
clearInternalTimer();
|
|
187
|
+
controller.abort(callerSignal.reason);
|
|
188
|
+
};
|
|
189
|
+
callerSignal.addEventListener('abort', onCallerAbort, { once: true });
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
// No point arming a timeout if the caller already cancelled.
|
|
193
|
+
if (!didCallerAbort && Number.isFinite(timeoutMs) && timeoutMs > 0) {
|
|
194
|
+
timer = setTimeout(() => {
|
|
195
|
+
didTimeout = true;
|
|
196
|
+
controller.abort();
|
|
197
|
+
}, timeoutMs);
|
|
198
|
+
}
|
|
199
|
+
return {
|
|
200
|
+
signal: controller.signal,
|
|
201
|
+
timedOut: () => didTimeout,
|
|
202
|
+
callerAborted: () => didCallerAbort,
|
|
203
|
+
cleanup: () => {
|
|
204
|
+
clearInternalTimer();
|
|
205
|
+
if (callerSignal && onCallerAbort) {
|
|
206
|
+
callerSignal.removeEventListener('abort', onCallerAbort);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
export class AetherClient {
|
|
212
|
+
trades;
|
|
213
|
+
accounts;
|
|
214
|
+
stats;
|
|
215
|
+
alerts;
|
|
216
|
+
market;
|
|
217
|
+
diary;
|
|
218
|
+
/**
|
|
219
|
+
* When `true`, resources validate responses with their Zod parser before
|
|
220
|
+
* returning. Exposed (readonly) so resource factories can read it.
|
|
221
|
+
*/
|
|
222
|
+
validateResponses;
|
|
223
|
+
config;
|
|
224
|
+
fetchImpl;
|
|
225
|
+
timeoutMs;
|
|
226
|
+
constructor(config) {
|
|
227
|
+
// Browser guard first: never even reach the point of holding a secret in
|
|
228
|
+
// a context that would leak it.
|
|
229
|
+
assertNotBrowserUnlessAllowed(config.dangerouslyAllowBrowser ?? false);
|
|
230
|
+
// Reject a blank OR whitespace-only key. The short-circuit order matters:
|
|
231
|
+
// `!config.auth.apiKey` guards `''`/undefined before `.trim()` runs. The
|
|
232
|
+
// key is validated, NOT trimmed — a whitespace key is a caller config bug
|
|
233
|
+
// to surface, not silently paper over.
|
|
234
|
+
if (!config.auth ||
|
|
235
|
+
config.auth.type !== 'apiKey' ||
|
|
236
|
+
!config.auth.apiKey ||
|
|
237
|
+
config.auth.apiKey.trim().length === 0) {
|
|
238
|
+
throw new Error('AetherClient: auth.apiKey is required');
|
|
239
|
+
}
|
|
240
|
+
// Default to the production API; only override for staging or local dev.
|
|
241
|
+
const baseUrl = (config.baseUrl?.trim() || DEFAULT_BASE_URL).replace(/\/$/, '');
|
|
242
|
+
assertHttpsUnlessLoopback(baseUrl);
|
|
243
|
+
this.config = { ...config, baseUrl };
|
|
244
|
+
this.fetchImpl = config.fetchImpl ?? globalThis.fetch.bind(globalThis);
|
|
245
|
+
this.timeoutMs = config.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
246
|
+
this.validateResponses = config.validateResponses ?? false;
|
|
247
|
+
this.trades = createTradesResource(this);
|
|
248
|
+
this.accounts = createAccountsResource(this);
|
|
249
|
+
this.stats = createStatsResource(this);
|
|
250
|
+
this.alerts = createAlertsResource(this);
|
|
251
|
+
this.market = createMarketResource(this);
|
|
252
|
+
this.diary = createDiaryResource(this);
|
|
253
|
+
}
|
|
254
|
+
/**
|
|
255
|
+
* Low-level request used by resource implementations and as an escape hatch
|
|
256
|
+
* for routes the SDK doesn't model. Returns the parsed `data` field when the
|
|
257
|
+
* envelope has one (e.g. `{success, data}`), otherwise the whole parsed
|
|
258
|
+
* object (so resources can `pluck` their named key). Throws a typed
|
|
259
|
+
* `AetherApiError` subclass on any non-2xx or `{success: false}`.
|
|
260
|
+
*/
|
|
261
|
+
async request(path, init = {}) {
|
|
262
|
+
const method = init.method ?? 'GET';
|
|
263
|
+
const url = `${this.config.baseUrl}${path}${buildQueryString(init.query)}`;
|
|
264
|
+
const headers = this.buildHeaders(init);
|
|
265
|
+
const fetchInit = { method, headers };
|
|
266
|
+
if (init.body !== undefined) {
|
|
267
|
+
fetchInit.body = JSON.stringify(init.body);
|
|
268
|
+
}
|
|
269
|
+
const timeoutMs = init.timeoutMs ?? this.timeoutMs;
|
|
270
|
+
const abort = armAbort(init.signal, timeoutMs);
|
|
271
|
+
fetchInit.signal = abort.signal;
|
|
272
|
+
this.config.onRequest?.({ method, url });
|
|
273
|
+
const startedAt = this.tick();
|
|
274
|
+
try {
|
|
275
|
+
const response = await this.fetchImpl(url, fetchInit);
|
|
276
|
+
this.config.onResponse?.({
|
|
277
|
+
method,
|
|
278
|
+
url,
|
|
279
|
+
status: response.status,
|
|
280
|
+
ms: this.tick() - startedAt
|
|
281
|
+
});
|
|
282
|
+
// Read + classify the body while STILL inside the abort-covered scope
|
|
283
|
+
// so a stalled body (or a caller-abort during the read) is caught by
|
|
284
|
+
// the classifier below instead of hanging past the timeout. `return
|
|
285
|
+
// await` (not a bare `return`) keeps the read inside this `try`, so
|
|
286
|
+
// `finally` cannot tear down the timer/listener mid-read.
|
|
287
|
+
return await this.parseResponse(response, path, method);
|
|
288
|
+
}
|
|
289
|
+
catch (error) {
|
|
290
|
+
this.throwRequestError({
|
|
291
|
+
error,
|
|
292
|
+
abort,
|
|
293
|
+
callerSignal: init.signal,
|
|
294
|
+
path,
|
|
295
|
+
method,
|
|
296
|
+
timeoutMs,
|
|
297
|
+
startedAt
|
|
298
|
+
});
|
|
299
|
+
}
|
|
300
|
+
finally {
|
|
301
|
+
// Release the timer + abort listener once the body is fully read
|
|
302
|
+
// (success) or the request has errored — never before.
|
|
303
|
+
abort.cleanup();
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
/**
|
|
307
|
+
* Classify a thrown request error, in priority order:
|
|
308
|
+
* (a) caller-initiated cancellation → re-throw the caller's reason as-is
|
|
309
|
+
* (unwrapped, non-retryable: `withRetry` must not retry a cancel);
|
|
310
|
+
* (b) our internal timeout fired → {@link AetherTimeoutError};
|
|
311
|
+
* (c) a typed API error from `parseResponse` (4xx / `{success:false}`) →
|
|
312
|
+
* propagate unchanged (an application error, not a transport/abort one);
|
|
313
|
+
* (d) anything else → {@link AetherNetworkError} (DNS/TCP/TLS, stalled read).
|
|
314
|
+
*/
|
|
315
|
+
throwRequestError(args) {
|
|
316
|
+
const { error, abort, callerSignal, path, method, timeoutMs, startedAt } = args;
|
|
317
|
+
// Check the internal timeout FIRST: a caller-abort clears the timer, so
|
|
318
|
+
// `timedOut()` is true only when the timeout fired first — checking it
|
|
319
|
+
// ahead of the caller-abort stops a tight timeout→cancel race from being
|
|
320
|
+
// mis-reported as a caller cancellation.
|
|
321
|
+
if (abort.timedOut()) {
|
|
322
|
+
throw new AetherTimeoutError({
|
|
323
|
+
message: `Request ${method} ${path} timed out after ${timeoutMs}ms`,
|
|
324
|
+
cause: redactCause(error, this.redactionSecret()),
|
|
325
|
+
path,
|
|
326
|
+
method,
|
|
327
|
+
timeoutMs,
|
|
328
|
+
elapsedMs: Math.round(this.tick() - startedAt)
|
|
329
|
+
});
|
|
330
|
+
}
|
|
331
|
+
// Caller cancelled: re-throw their reason unwrapped (non-retryable —
|
|
332
|
+
// `withRetry` must not retry a deliberate cancel).
|
|
333
|
+
if (abort.callerAborted() || callerSignal?.aborted) {
|
|
334
|
+
throw callerSignal?.reason ?? error;
|
|
335
|
+
}
|
|
336
|
+
if (error instanceof AetherApiError)
|
|
337
|
+
throw error;
|
|
338
|
+
throw new AetherNetworkError({
|
|
339
|
+
message: `Network error calling ${method} ${path}`,
|
|
340
|
+
cause: redactCause(error, this.redactionSecret()),
|
|
341
|
+
path,
|
|
342
|
+
method
|
|
343
|
+
});
|
|
344
|
+
}
|
|
345
|
+
/**
|
|
346
|
+
* Turn a fetched `Response` into the resolved value or a typed throw:
|
|
347
|
+
* classifies non-2xx / `{success:false}`, unwraps a literal `data` envelope,
|
|
348
|
+
* and otherwise returns the whole parsed object for the resource to `pluck`.
|
|
349
|
+
*/
|
|
350
|
+
async parseResponse(response, path, method) {
|
|
351
|
+
const text = await response.text();
|
|
352
|
+
let parsed;
|
|
353
|
+
if (text.length > 0) {
|
|
354
|
+
try {
|
|
355
|
+
parsed = JSON.parse(text);
|
|
356
|
+
}
|
|
357
|
+
catch {
|
|
358
|
+
parsed = text;
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
if (!response.ok) {
|
|
362
|
+
this.throwClassifiedError(parsed, response, path, method);
|
|
363
|
+
}
|
|
364
|
+
if (response.status === 204)
|
|
365
|
+
return undefined;
|
|
366
|
+
if (parsed === undefined) {
|
|
367
|
+
throw new AetherApiError(`Empty response body from ${method} ${path}`, {
|
|
368
|
+
status: response.status,
|
|
369
|
+
code: 'EMPTY_BODY',
|
|
370
|
+
path,
|
|
371
|
+
method,
|
|
372
|
+
responseBody: text
|
|
373
|
+
});
|
|
374
|
+
}
|
|
375
|
+
if (typeof parsed !== 'object' || parsed === null)
|
|
376
|
+
return parsed;
|
|
377
|
+
const envelope = parsed;
|
|
378
|
+
if (envelope.success === false) {
|
|
379
|
+
this.throwClassifiedError(parsed, response, path, method);
|
|
380
|
+
}
|
|
381
|
+
// `{success, data}` → unwrap `data`; any other `{success, <key>}`
|
|
382
|
+
// envelope is returned whole for the resource to `pluck`.
|
|
383
|
+
if ('data' in envelope)
|
|
384
|
+
return envelope.data;
|
|
385
|
+
return parsed;
|
|
386
|
+
}
|
|
387
|
+
/**
|
|
388
|
+
* Redact the API key from the parsed error body, then classify it into the
|
|
389
|
+
* most specific {@link AetherApiError} subtype. Redaction happens HERE — the
|
|
390
|
+
* single choke point for turning a response body into a thrown error — so a
|
|
391
|
+
* key echoed back by a proxy/WAF can never reach `err.message` or
|
|
392
|
+
* `err.responseBody`. The redacted body is a fresh structure; `parsed` (the
|
|
393
|
+
* caller's success-path value) is left untouched.
|
|
394
|
+
*/
|
|
395
|
+
throwClassifiedError(parsed, response, path, method) {
|
|
396
|
+
throw classifyError({
|
|
397
|
+
status: response.status,
|
|
398
|
+
body: redactSecret(parsed, this.redactionSecret()),
|
|
399
|
+
path,
|
|
400
|
+
method,
|
|
401
|
+
headers: response.headers
|
|
402
|
+
});
|
|
403
|
+
}
|
|
404
|
+
/** The API key to scrub from error bodies (empty when auth carries no key). */
|
|
405
|
+
redactionSecret() {
|
|
406
|
+
return this.config.auth.type === 'apiKey' ? this.config.auth.apiKey : '';
|
|
407
|
+
}
|
|
408
|
+
buildHeaders(init) {
|
|
409
|
+
const headers = {
|
|
410
|
+
'content-type': 'application/json',
|
|
411
|
+
accept: 'application/json'
|
|
412
|
+
};
|
|
413
|
+
if (this.config.auth.type === 'apiKey') {
|
|
414
|
+
headers['authorization'] = `Bearer ${this.config.auth.apiKey}`;
|
|
415
|
+
}
|
|
416
|
+
if (this.config.userAgent) {
|
|
417
|
+
headers['user-agent'] = this.config.userAgent;
|
|
418
|
+
}
|
|
419
|
+
if (init?.idempotencyKey) {
|
|
420
|
+
headers['idempotency-key'] = init.idempotencyKey;
|
|
421
|
+
}
|
|
422
|
+
return headers;
|
|
423
|
+
}
|
|
424
|
+
tick() {
|
|
425
|
+
return typeof performance !== 'undefined' && typeof performance.now === 'function'
|
|
426
|
+
? performance.now()
|
|
427
|
+
: Date.now();
|
|
428
|
+
}
|
|
429
|
+
}
|
package/dist/errors.d.ts
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Typed error hierarchy for the AetherWealth SDK.
|
|
3
|
+
*
|
|
4
|
+
* All HTTP errors thrown by `AetherClient` are subclasses of
|
|
5
|
+
* `AetherApiError`, so callers can do
|
|
6
|
+
* `catch (err) { if (err instanceof AetherApiError) … }`. The 4xx
|
|
7
|
+
* subtypes let callers branch without sniffing status codes.
|
|
8
|
+
*
|
|
9
|
+
* `AetherNetworkError` is intentionally NOT a subclass of
|
|
10
|
+
* `AetherApiError` — it has no HTTP status because the request never
|
|
11
|
+
* reached the server (DNS failure, connection refused, TLS rejection).
|
|
12
|
+
*/
|
|
13
|
+
export declare class AetherApiError extends Error {
|
|
14
|
+
readonly status: number;
|
|
15
|
+
readonly code: string;
|
|
16
|
+
readonly path: string;
|
|
17
|
+
readonly method: string;
|
|
18
|
+
readonly responseBody: unknown;
|
|
19
|
+
constructor(message: string, opts: {
|
|
20
|
+
status: number;
|
|
21
|
+
code: string;
|
|
22
|
+
path: string;
|
|
23
|
+
method: string;
|
|
24
|
+
responseBody?: unknown;
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
export declare class AetherAuthError extends AetherApiError {
|
|
28
|
+
readonly status: 401;
|
|
29
|
+
constructor(opts: {
|
|
30
|
+
message: string;
|
|
31
|
+
code: string;
|
|
32
|
+
path: string;
|
|
33
|
+
method: string;
|
|
34
|
+
responseBody?: unknown;
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
export declare class AetherForbiddenError extends AetherApiError {
|
|
38
|
+
readonly status: 403;
|
|
39
|
+
constructor(opts: {
|
|
40
|
+
message: string;
|
|
41
|
+
code: string;
|
|
42
|
+
path: string;
|
|
43
|
+
method: string;
|
|
44
|
+
responseBody?: unknown;
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
export declare class AetherNotFoundError extends AetherApiError {
|
|
48
|
+
readonly status: 404;
|
|
49
|
+
constructor(opts: {
|
|
50
|
+
message: string;
|
|
51
|
+
code: string;
|
|
52
|
+
path: string;
|
|
53
|
+
method: string;
|
|
54
|
+
responseBody?: unknown;
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
export declare class AetherValidationError extends AetherApiError {
|
|
58
|
+
constructor(opts: {
|
|
59
|
+
message: string;
|
|
60
|
+
code: string;
|
|
61
|
+
path: string;
|
|
62
|
+
method: string;
|
|
63
|
+
responseBody?: unknown;
|
|
64
|
+
status?: 400 | 422;
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
export declare class AetherRateLimitError extends AetherApiError {
|
|
68
|
+
readonly status: 429;
|
|
69
|
+
readonly retryAfterSeconds: number | null;
|
|
70
|
+
constructor(opts: {
|
|
71
|
+
message: string;
|
|
72
|
+
code: string;
|
|
73
|
+
path: string;
|
|
74
|
+
method: string;
|
|
75
|
+
retryAfterSeconds: number | null;
|
|
76
|
+
responseBody?: unknown;
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
export declare class AetherNetworkError extends Error {
|
|
80
|
+
readonly cause: unknown;
|
|
81
|
+
readonly path: string;
|
|
82
|
+
readonly method: string;
|
|
83
|
+
constructor(opts: {
|
|
84
|
+
message: string;
|
|
85
|
+
cause: unknown;
|
|
86
|
+
path: string;
|
|
87
|
+
method: string;
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Thrown when a request is aborted by the client's own timeout (not by a
|
|
92
|
+
* caller-supplied signal). Subclasses `AetherNetworkError` so existing
|
|
93
|
+
* `catch (e) { if (e instanceof AetherNetworkError) … }` blocks — and
|
|
94
|
+
* `withRetry` — treat a timeout as the transient network condition it is.
|
|
95
|
+
*
|
|
96
|
+
* `timeoutMs` is the configured budget; `elapsedMs` is how long the request
|
|
97
|
+
* actually ran before the abort fired (useful for logging/telemetry).
|
|
98
|
+
*/
|
|
99
|
+
export declare class AetherTimeoutError extends AetherNetworkError {
|
|
100
|
+
readonly timeoutMs: number;
|
|
101
|
+
readonly elapsedMs: number;
|
|
102
|
+
constructor(opts: {
|
|
103
|
+
message: string;
|
|
104
|
+
cause: unknown;
|
|
105
|
+
path: string;
|
|
106
|
+
method: string;
|
|
107
|
+
timeoutMs: number;
|
|
108
|
+
elapsedMs: number;
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Parse the `Retry-After` header. Per RFC 7231 the value may be either:
|
|
113
|
+
* - a decimal number of seconds (`Retry-After: 120`), or
|
|
114
|
+
* - an HTTP-date (`Retry-After: Sat, 25 Apr 2026 12:00:00 GMT`).
|
|
115
|
+
* Returns the number of seconds from now, or null if absent/unparseable.
|
|
116
|
+
* The `now` argument is injectable for deterministic tests.
|
|
117
|
+
*/
|
|
118
|
+
export declare function parseRetryAfter(headerValue: string | null | undefined, now?: () => number): number | null;
|
|
119
|
+
/**
|
|
120
|
+
* Factory: map an HTTP response into the most specific subtype.
|
|
121
|
+
* Preserves the response envelope so callers can inspect `responseBody`,
|
|
122
|
+
* and reads `Retry-After` for 429s when headers are available.
|
|
123
|
+
*/
|
|
124
|
+
export declare function classifyError(args: {
|
|
125
|
+
status: number;
|
|
126
|
+
body: unknown;
|
|
127
|
+
path: string;
|
|
128
|
+
method: string;
|
|
129
|
+
headers?: Headers | null;
|
|
130
|
+
now?: () => number;
|
|
131
|
+
}): AetherApiError;
|