nansen-cli 1.34.0 → 1.35.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/CHANGELOG.md +18 -0
- package/README.md +14 -3
- package/package.json +1 -1
- package/scripts/postinstall.js +13 -3
- package/src/api.js +56 -5
- package/src/cli.js +9 -0
- package/src/response-meta.js +123 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,23 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.35.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- [#469](https://github.com/nansen-ai/nansen-cli/pull/469) [`85b1934`](https://github.com/nansen-ai/nansen-cli/commit/85b1934ae25ed02d1726de8ebe92ea41a98f4454) Thanks [@gulshngill](https://github.com/gulshngill)! - Surface the API's credit and rate-limit response headers.
|
|
8
|
+
|
|
9
|
+
Failed calls now report quota state in their error details: an out-of-credits error carries your actual remaining balance, and a rate-limited error carries the limit, what is left, and how long the window needs to drain. Previously the only credit figure the CLI could show was the static per-endpoint estimate published in the API reference — a quote, not what you were charged.
|
|
10
|
+
|
|
11
|
+
A warning goes to stderr when your balance will not cover another call of the size just made, so it never interferes with the JSON on stdout.
|
|
12
|
+
|
|
13
|
+
Successful responses carry the same numbers under an exported `RESPONSE_META` symbol, and the client exposes `lastResponseMeta`. Both are additive: the JSON each command prints is unchanged.
|
|
14
|
+
|
|
15
|
+
- [#470](https://github.com/nansen-ai/nansen-cli/pull/470) [`8159300`](https://github.com/nansen-ai/nansen-cli/commit/81593008f829cc83f1d4a6ee9e5c1a10237a9553) Thanks [@gulshngill](https://github.com/gulshngill)! - Surface the API's request id.
|
|
16
|
+
|
|
17
|
+
Failed calls now carry `details.requestId` — the value that identifies the call end to end. Quote it when reporting a problem; previously nothing identifying a failed request ever reached the user, which made server errors effectively unreportable. Successful responses expose it alongside the credit and rate-limit figures under the `RESPONSE_META` symbol.
|
|
18
|
+
|
|
19
|
+
Absent on deployments that do not send the header yet, in which case the field is simply omitted.
|
|
20
|
+
|
|
3
21
|
## 1.34.0
|
|
4
22
|
|
|
5
23
|
### Minor Changes
|
package/README.md
CHANGED
|
@@ -177,17 +177,28 @@ nansen research smart-money netflow --chain solana --fields token_symbol,net_flo
|
|
|
177
177
|
|
|
178
178
|
```json
|
|
179
179
|
{ "success": true, "data": <api_response> }
|
|
180
|
-
{ "success": false, "error": "message", "code": "ERROR_CODE", "status": 401 }
|
|
180
|
+
{ "success": false, "error": "message", "code": "ERROR_CODE", "status": 401, "details": { ... } }
|
|
181
181
|
```
|
|
182
182
|
|
|
183
183
|
**Critical error codes:**
|
|
184
184
|
|
|
185
185
|
| Code | Action |
|
|
186
186
|
|------|--------|
|
|
187
|
-
| `CREDITS_EXHAUSTED` | Stop all API calls immediately.
|
|
187
|
+
| `CREDITS_EXHAUSTED` | Stop all API calls immediately. `details.credits.remaining` is your actual balance. Top up at [app.nansen.ai/api](https://app.nansen.ai/api). |
|
|
188
188
|
| `UNAUTHORIZED` | Wrong or missing key. Re-auth. |
|
|
189
|
-
| `RATE_LIMITED` | Auto-retried by CLI. |
|
|
189
|
+
| `RATE_LIMITED` | Auto-retried by CLI. `details.rateLimit.resetSeconds` is how long the window needs to drain. |
|
|
190
190
|
| `UNSUPPORTED_FILTER` | Remove the filter and retry. |
|
|
191
|
+
| `SERVER_ERROR` | Not your fault. Quote `details.requestId` when reporting it. |
|
|
192
|
+
|
|
193
|
+
**Error metadata.** When the API reports them, `details` carries:
|
|
194
|
+
|
|
195
|
+
| Field | Meaning |
|
|
196
|
+
|-------|---------|
|
|
197
|
+
| `requestId` | Identifies this call end to end. Quote it in any support report. Opaque — do not parse it. |
|
|
198
|
+
| `credits` | `used`, `remaining` |
|
|
199
|
+
| `rateLimit` | `limit`, `remaining`, `resetSeconds` |
|
|
200
|
+
|
|
201
|
+
Any field may be absent or `null`, meaning unknown — never assume zero. A low-balance warning goes to **stderr**, so stdout stays pure JSON.
|
|
191
202
|
|
|
192
203
|
## Troubleshooting
|
|
193
204
|
|
package/package.json
CHANGED
package/scripts/postinstall.js
CHANGED
|
@@ -41,9 +41,19 @@ function hasTTY() {
|
|
|
41
41
|
return process.stdin.isTTY && process.stderr.isTTY;
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
+
// npx is a .cmd shim on Windows, and Node refuses to spawn .cmd/.bat without a
|
|
45
|
+
// shell (CVE-2024-27980). Go through cmd.exe explicitly rather than enabling
|
|
46
|
+
// `shell: true`, which would hand the whole command line to the shell parser.
|
|
47
|
+
const IS_WIN = process.platform === "win32";
|
|
48
|
+
|
|
49
|
+
function npxInvocation(args) {
|
|
50
|
+
return IS_WIN ? ["cmd.exe", ["/c", "npx", ...args]] : ["npx", args];
|
|
51
|
+
}
|
|
52
|
+
|
|
44
53
|
function hasNpx() {
|
|
45
54
|
try {
|
|
46
|
-
|
|
55
|
+
const [cmd, cmdArgs] = npxInvocation(["--version"]);
|
|
56
|
+
execFileSync(cmd, cmdArgs, { stdio: "ignore", shell: false });
|
|
47
57
|
return true;
|
|
48
58
|
} catch {
|
|
49
59
|
return false;
|
|
@@ -86,7 +96,7 @@ function prompt(question) {
|
|
|
86
96
|
|
|
87
97
|
function runCommand(cmd, args) {
|
|
88
98
|
return new Promise((resolve) => {
|
|
89
|
-
const child = spawn(cmd, args, { stdio: "inherit", shell:
|
|
99
|
+
const child = spawn(cmd, args, { stdio: "inherit", shell: false });
|
|
90
100
|
child.on("close", (code) => resolve(code === 0));
|
|
91
101
|
child.on("error", () => resolve(false));
|
|
92
102
|
});
|
|
@@ -113,7 +123,7 @@ async function installSkill() {
|
|
|
113
123
|
}
|
|
114
124
|
|
|
115
125
|
log(`Installing Nansen skill...`);
|
|
116
|
-
const ok = await runCommand(
|
|
126
|
+
const ok = await runCommand(...npxInvocation(["-y", "skills", "add", SKILL_REPO]));
|
|
117
127
|
if (!ok) {
|
|
118
128
|
log(`${YELLOW}Skill installation failed. You can retry with: npx skills add ${SKILL_REPO}${RESET}`);
|
|
119
129
|
}
|
package/src/api.js
CHANGED
|
@@ -8,6 +8,16 @@ import path from 'path';
|
|
|
8
8
|
import { fileURLToPath } from 'url';
|
|
9
9
|
import { EVM_CHAINS } from './chain-ids.js';
|
|
10
10
|
import { getAnonymousId, TELEMETRY_DISABLED } from './telemetry.js';
|
|
11
|
+
import { readResponseMeta } from './response-meta.js';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Key for the credit/rate-limit metadata attached to a successful response.
|
|
15
|
+
*
|
|
16
|
+
* A symbol on purpose: JSON.stringify and Object.keys both skip it, so the JSON
|
|
17
|
+
* every command prints is byte-for-byte unchanged while callers that want the
|
|
18
|
+
* numbers can still read them off the returned object.
|
|
19
|
+
*/
|
|
20
|
+
export const RESPONSE_META = Symbol('nansenResponseMeta');
|
|
11
21
|
|
|
12
22
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
13
23
|
|
|
@@ -465,6 +475,15 @@ export class NansenAPI {
|
|
|
465
475
|
ttl: options.cache?.ttl ?? DEFAULT_CACHE_TTL
|
|
466
476
|
};
|
|
467
477
|
this.defaultHeaders = options.defaultHeaders || {};
|
|
478
|
+
/**
|
|
479
|
+
* Credit/rate-limit metadata from the most recent response, or null.
|
|
480
|
+
*
|
|
481
|
+
* Survives any reshaping a command handler does to the response body, which
|
|
482
|
+
* the RESPONSE_META symbol on the returned object does not. Last write wins
|
|
483
|
+
* when a handler makes several calls — the freshest balance, which is what a
|
|
484
|
+
* low-credit warning wants.
|
|
485
|
+
*/
|
|
486
|
+
this.lastResponseMeta = null;
|
|
468
487
|
}
|
|
469
488
|
|
|
470
489
|
static cleanBody(body) {
|
|
@@ -520,7 +539,11 @@ export class NansenAPI {
|
|
|
520
539
|
}
|
|
521
540
|
} catch { /* balance check is best-effort */ }
|
|
522
541
|
}
|
|
523
|
-
|
|
542
|
+
const data = await paidResponse.json();
|
|
543
|
+
const meta = readResponseMeta(paidResponse);
|
|
544
|
+
this.lastResponseMeta = meta;
|
|
545
|
+
if (meta && data !== null && typeof data === 'object') data[RESPONSE_META] = meta;
|
|
546
|
+
return data;
|
|
524
547
|
}
|
|
525
548
|
|
|
526
549
|
async request(endpoint, body = {}, options = {}) {
|
|
@@ -581,11 +604,17 @@ export class NansenAPI {
|
|
|
581
604
|
data = await response.json();
|
|
582
605
|
} catch (_err) {
|
|
583
606
|
// Non-JSON response (rare, usually server errors)
|
|
607
|
+
const meta = readResponseMeta(response);
|
|
608
|
+
this.lastResponseMeta = meta;
|
|
584
609
|
const error = new NansenError(
|
|
585
610
|
`Invalid response from API (status ${response.status})`,
|
|
586
611
|
response.status >= 500 ? ErrorCode.SERVER_ERROR : ErrorCode.UNKNOWN,
|
|
587
612
|
response.status,
|
|
588
|
-
{
|
|
613
|
+
{
|
|
614
|
+
body: await response.text().catch(() => null),
|
|
615
|
+
attempt: attempt + 1,
|
|
616
|
+
...(meta?.requestId && { requestId: meta.requestId })
|
|
617
|
+
}
|
|
589
618
|
);
|
|
590
619
|
|
|
591
620
|
if (shouldRetry && attempt < maxRetries && response.status >= 500) {
|
|
@@ -702,10 +731,23 @@ export class NansenAPI {
|
|
|
702
731
|
}
|
|
703
732
|
}
|
|
704
733
|
|
|
734
|
+
// Quota state and the request id belong on the error above all: an
|
|
735
|
+
// out-of-credits or rate-limited failure is exactly when the caller
|
|
736
|
+
// needs the numbers, and a 5xx is worthless to support without the id.
|
|
737
|
+
// formatError() surfaces details, so this needs no plumbing.
|
|
738
|
+
//
|
|
739
|
+
// On a retried call this is the LAST attempt's id — each attempt gets
|
|
740
|
+
// its own server-side id, and the last one is the failure worth
|
|
741
|
+
// reporting.
|
|
742
|
+
const meta = readResponseMeta(response);
|
|
743
|
+
this.lastResponseMeta = meta;
|
|
705
744
|
lastError = new NansenError(message, code, response.status, {
|
|
706
745
|
...data,
|
|
707
746
|
attempt: attempt + 1,
|
|
708
|
-
retryAfterMs
|
|
747
|
+
retryAfterMs,
|
|
748
|
+
...(meta?.requestId && { requestId: meta.requestId }),
|
|
749
|
+
...(meta?.credits && { credits: meta.credits }),
|
|
750
|
+
...(meta?.rateLimit && { rateLimit: meta.rateLimit })
|
|
709
751
|
});
|
|
710
752
|
|
|
711
753
|
// Retry on specific status codes
|
|
@@ -722,12 +764,21 @@ export class NansenAPI {
|
|
|
722
764
|
if (attempt > 0) {
|
|
723
765
|
data._meta = { ...(data._meta || {}), retriedAttempts: attempt };
|
|
724
766
|
}
|
|
725
|
-
|
|
767
|
+
|
|
726
768
|
// Cache successful response
|
|
727
769
|
if (useCache) {
|
|
728
770
|
setCachedResponse(endpoint, body, data);
|
|
729
771
|
}
|
|
730
|
-
|
|
772
|
+
|
|
773
|
+
// Attach after caching so the cache stores the payload alone — quota
|
|
774
|
+
// numbers are per-response and would be stale on a cache hit.
|
|
775
|
+
// Guarded: a response body can be a primitive, which cannot take a property.
|
|
776
|
+
const meta = readResponseMeta(response);
|
|
777
|
+
this.lastResponseMeta = meta;
|
|
778
|
+
if (meta) {
|
|
779
|
+
if (data !== null && typeof data === 'object') data[RESPONSE_META] = meta;
|
|
780
|
+
}
|
|
781
|
+
|
|
731
782
|
return data;
|
|
732
783
|
}
|
|
733
784
|
|
package/src/cli.js
CHANGED
|
@@ -14,6 +14,7 @@ import { resolveAddress, isEnsName } from './ens.js';
|
|
|
14
14
|
import fs from 'fs';
|
|
15
15
|
import { getUpdateNotification, getUpgradeNotice, scheduleUpdateCheck } from './update-check.js';
|
|
16
16
|
import { refreshCostMapIfStale, getCostForEndpoint } from './cost-cache.js';
|
|
17
|
+
import { creditWarning, noticeWarnings } from './response-meta.js';
|
|
17
18
|
import { trackCommandSucceeded, trackCommandFailed } from './telemetry.js';
|
|
18
19
|
import { createRequire } from 'module';
|
|
19
20
|
import * as readline from 'readline';
|
|
@@ -1854,6 +1855,14 @@ export async function runCLI(rawArgs, deps = {}) {
|
|
|
1854
1855
|
const api = new NansenAPIClass(undefined, undefined, { retry: retryOptions, cache: cacheOptions, defaultHeaders });
|
|
1855
1856
|
let result = await commands[command](subArgs, api, flags, options);
|
|
1856
1857
|
|
|
1858
|
+
// Credit balance warning, from the headers on the call just made. Goes to
|
|
1859
|
+
// stderr so it never contaminates the JSON on stdout that agents parse.
|
|
1860
|
+
// Placed before every return path below so it fires for operational
|
|
1861
|
+
// commands too, which print their own output and return undefined.
|
|
1862
|
+
const lowCredits = creditWarning(api.lastResponseMeta);
|
|
1863
|
+
if (lowCredits) errorOutput(lowCredits);
|
|
1864
|
+
for (const notice of noticeWarnings(api.lastResponseMeta)) errorOutput(notice);
|
|
1865
|
+
|
|
1857
1866
|
// Commands that handle their own output return undefined
|
|
1858
1867
|
if (result === undefined) {
|
|
1859
1868
|
await trackCommandSucceeded({ command: fullCommand, duration_ms: Date.now() - startTime, flags: usedFlags, chain });
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Request-id, credit, rate-limit, and notice metadata, read from Nansen API
|
|
3
|
+
* response headers.
|
|
4
|
+
*
|
|
5
|
+
* The API reports what a call actually cost, what quota is left, and an id that
|
|
6
|
+
* identifies the call end to end. Until now the CLI dropped those headers on the
|
|
7
|
+
* floor and showed only the static per-endpoint estimate published in the
|
|
8
|
+
* OpenAPI spec (see cost-cache.js), which is a quote rather than a charge.
|
|
9
|
+
*
|
|
10
|
+
* readResponseMeta(response) — parse the headers, or null if none are present
|
|
11
|
+
* creditWarning(meta) — stderr warning string when the balance is short, else null
|
|
12
|
+
*
|
|
13
|
+
* Every header is optional. Some auth rails charge no credits, some responses
|
|
14
|
+
* are served before quota is resolved, and older deployments may send neither
|
|
15
|
+
* the rate-limit triplet nor the request id — so a missing header means
|
|
16
|
+
* "unknown", never zero.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/** Header names, as documented in the API reference. */
|
|
20
|
+
const CREDITS_USED = 'x-nansen-credits-used';
|
|
21
|
+
const CREDITS_REMAINING = 'x-nansen-credits-remaining';
|
|
22
|
+
const RATE_LIMIT = 'x-ratelimit-limit';
|
|
23
|
+
const RATE_REMAINING = 'x-ratelimit-remaining';
|
|
24
|
+
const RATE_RESET = 'x-ratelimit-reset';
|
|
25
|
+
const UPGRADE_HINT = 'x-nansen-upgrade-hint';
|
|
26
|
+
const PLAN_NOTICE = 'x-nansen-plan-notice';
|
|
27
|
+
const API_KEY_NOTICE = 'x-nansen-api-key-notice';
|
|
28
|
+
const REQUEST_ID = 'x-request-id';
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Read a header as a non-negative integer, or null when absent/unparseable.
|
|
32
|
+
* Tolerates any header bag with a .get() — a real Headers, or a Map in tests.
|
|
33
|
+
*/
|
|
34
|
+
function intHeader(response, name) {
|
|
35
|
+
const raw = stringHeader(response, name);
|
|
36
|
+
if (raw === null) return null;
|
|
37
|
+
const value = Number.parseInt(raw, 10);
|
|
38
|
+
return Number.isInteger(value) && value >= 0 ? value : null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Read a header as a trimmed non-empty string, or null when absent.
|
|
43
|
+
* Tolerates any header bag with a .get() — a real Headers, or a Map in tests.
|
|
44
|
+
*/
|
|
45
|
+
function stringHeader(response, name) {
|
|
46
|
+
const raw = response?.headers?.get?.(name);
|
|
47
|
+
if (raw == null) return null;
|
|
48
|
+
const value = String(raw).trim();
|
|
49
|
+
return value === '' ? null : value;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Extract request-id, credit, rate-limit, and notice metadata from a fetch Response.
|
|
54
|
+
* Returns null when the response carries none of it, so callers can skip
|
|
55
|
+
* attaching an object full of nulls.
|
|
56
|
+
*/
|
|
57
|
+
export function readResponseMeta(response) {
|
|
58
|
+
const used = intHeader(response, CREDITS_USED);
|
|
59
|
+
const remaining = intHeader(response, CREDITS_REMAINING);
|
|
60
|
+
const limit = intHeader(response, RATE_LIMIT);
|
|
61
|
+
const rateRemaining = intHeader(response, RATE_REMAINING);
|
|
62
|
+
const resetSeconds = intHeader(response, RATE_RESET);
|
|
63
|
+
const upgradeHint = stringHeader(response, UPGRADE_HINT);
|
|
64
|
+
const planNotice = stringHeader(response, PLAN_NOTICE);
|
|
65
|
+
const apiKeyNotice = stringHeader(response, API_KEY_NOTICE);
|
|
66
|
+
const requestId = stringHeader(response, REQUEST_ID);
|
|
67
|
+
|
|
68
|
+
const meta = {};
|
|
69
|
+
if (requestId !== null) {
|
|
70
|
+
// The single value that identifies this call to Nansen support. Opaque —
|
|
71
|
+
// never parse it or assume a format.
|
|
72
|
+
meta.requestId = requestId;
|
|
73
|
+
}
|
|
74
|
+
if (used !== null || remaining !== null) {
|
|
75
|
+
meta.credits = { used, remaining };
|
|
76
|
+
}
|
|
77
|
+
if (limit !== null || rateRemaining !== null || resetSeconds !== null) {
|
|
78
|
+
// resetSeconds is a delta in seconds — how long the tripped window needs to
|
|
79
|
+
// drain — not a wall-clock timestamp.
|
|
80
|
+
meta.rateLimit = { limit, remaining: rateRemaining, resetSeconds };
|
|
81
|
+
}
|
|
82
|
+
if (upgradeHint !== null || planNotice !== null || apiKeyNotice !== null) {
|
|
83
|
+
meta.notices = {
|
|
84
|
+
...(upgradeHint !== null && { upgradeHint }),
|
|
85
|
+
...(planNotice !== null && { planNotice }),
|
|
86
|
+
...(apiKeyNotice !== null && { apiKeyNotice }),
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
return Object.keys(meta).length > 0 ? meta : null;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Yield notice strings for any server-set advisory headers.
|
|
94
|
+
* Each yields a `⚠️ <message>` line for stderr.
|
|
95
|
+
* Order: apiKeyNotice (most urgent) → upgradeHint → planNotice.
|
|
96
|
+
*/
|
|
97
|
+
export function noticeWarnings(meta) {
|
|
98
|
+
const notices = meta?.notices;
|
|
99
|
+
if (!notices) return [];
|
|
100
|
+
const out = [];
|
|
101
|
+
if (notices.apiKeyNotice) out.push(`⚠️ ${notices.apiKeyNotice}`);
|
|
102
|
+
if (notices.upgradeHint) out.push(`ℹ️ ${notices.upgradeHint}`);
|
|
103
|
+
if (notices.planNotice) out.push(`ℹ️ ${notices.planNotice}`);
|
|
104
|
+
return out;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Warn only when the remaining balance will not cover another call of the size
|
|
109
|
+
* just made.
|
|
110
|
+
*/
|
|
111
|
+
export function creditWarning(meta) {
|
|
112
|
+
const credits = meta?.credits;
|
|
113
|
+
if (!credits) return null;
|
|
114
|
+
const { used, remaining } = credits;
|
|
115
|
+
if (remaining === null) return null;
|
|
116
|
+
if (remaining === 0) {
|
|
117
|
+
return '⚠️ Out of API credits. Top up at https://app.nansen.ai/api';
|
|
118
|
+
}
|
|
119
|
+
if (used !== null && used > 0 && remaining < used) {
|
|
120
|
+
return `⚠️ ${remaining} API credit${remaining === 1 ? '' : 's'} left — less than this call cost (${used}). Top up at https://app.nansen.ai/api`;
|
|
121
|
+
}
|
|
122
|
+
return null;
|
|
123
|
+
}
|