fix rate limit handling for skills

This commit is contained in:
deepfeature
2026-04-01 11:05:47 +08:00
parent 09351a9205
commit 221bf120a8
7 changed files with 340 additions and 31 deletions
+21
View File
@@ -457,6 +457,25 @@ npx gmgn-cli order get --chain <chain> --order-id <order_id> [--raw]
**Response fields (data):** Same structure as the `swap` response above.
## Rate Limit Handling
All business routes are protected by GMGN's leaky-bucket limiter. Current production behavior is:
- `rate=10`, `capacity=10`
- every limited `429` response includes `X-RateLimit-Reset`
- `X-RateLimit-Reset` is a Unix timestamp in seconds, representing when the current cooldown is expected to end
CLI behavior:
- For read-only commands, `gmgn-cli` may wait until `X-RateLimit-Reset` and retry once automatically when the remaining cooldown is short.
- For longer cooldowns, or for `swap`, the CLI stops and prints the exact reset time instead of repeatedly sending requests.
- The auto-retry threshold defaults to `5000ms` and can be overridden with `GMGN_RATE_LIMIT_AUTO_RETRY_MAX_WAIT_MS=<milliseconds>`.
Important notes:
- `RATE_LIMIT_EXCEEDED` and `RATE_LIMIT_BANNED` are request-frequency limits. Continuing to send requests during the cooldown can extend the ban by 5 seconds each time, up to 5 minutes.
- `ERROR_RATE_LIMIT_BLOCKED` is an error-count block on `POST /v1/trade/swap`. It is triggered by repeatedly hitting the same business error and should be treated as "fix the request first, then retry after reset".
---
## Error Codes
@@ -471,6 +490,8 @@ npx gmgn-cli order get --chain <chain> --order-id <order_id> [--raw]
| `AUTH_CLIENT_ID_REPLAYED` | 401 | client_id replayed within 7s |
| `AUTH_REPLAY_CHECK_UNAVAILABLE` | 503 | Anti-replay Redis unavailable (critical auth only) |
| `RATE_LIMIT_EXCEEDED` | 429 | Rate limit exceeded |
| `RATE_LIMIT_BANNED` | 429 | Temporarily banned due to repeated rate limit violations |
| `ERROR_RATE_LIMIT_BLOCKED` | 429 | Temporarily blocked after repeated business errors on `swap` |
| `TRADE_WALLET_MISMATCH` | 403 | `--from` address does not match the wallet bound to the API Key |
| `CHAIN_NOT_SUPPORTED` | 400 | Unsupported chain |
| `BAD_REQUEST` | 400 | Missing or invalid request parameters |
+16
View File
@@ -58,6 +58,22 @@ Use the `gmgn-cli` tool to query K-line data for a token, browse trending tokens
- `gmgn-cli` installed globally — if missing, run: `npm install -g gmgn-cli`
- `GMGN_API_KEY` configured in `~/.config/gmgn/.env`
## Rate Limit Handling
All market routes used by this skill go through GMGN's leaky-bucket limiter with `rate=10` and `capacity=10`. Sustained throughput is roughly `10 ÷ weight` requests/second, and the max burst is roughly `floor(10 ÷ weight)` when the bucket is full.
| Command | Route | Weight |
|---------|-------|--------|
| `market kline` | `GET /v1/market/token_kline` | 2 |
| `market trending` | `GET /v1/market/rank` | 1 |
| `market trenches` | `POST /v1/trenches` | 3 |
When a request returns `429`:
- Read `X-RateLimit-Reset` from the response headers. It is a Unix timestamp in seconds that marks when the limit is expected to reset.
- The CLI may wait and retry once automatically when the remaining cooldown is short. If it still fails, stop and tell the user the exact retry time instead of sending more requests.
- For `RATE_LIMIT_EXCEEDED` or `RATE_LIMIT_BANNED`, repeated requests during the cooldown can extend the ban by 5 seconds each time, up to 5 minutes. Do not spam retries.
**First-time setup** (if `GMGN_API_KEY` is not configured):
1. Generate key pair and show the public key to the user:
+18
View File
@@ -49,6 +49,24 @@ Use the `gmgn-cli` tool to query wallet portfolio data based on the user's reque
- `gmgn-cli` installed globally — if missing, run: `npm install -g gmgn-cli`
- `GMGN_API_KEY` configured in `~/.config/gmgn/.env`
## Rate Limit Handling
All portfolio routes used by this skill go through GMGN's leaky-bucket limiter with `rate=10` and `capacity=10`. Sustained throughput is roughly `10 ÷ weight` requests/second, and the max burst is roughly `floor(10 ÷ weight)` when the bucket is full.
| Command | Route | Weight |
|---------|-------|--------|
| `portfolio info` | `GET /v1/user/info` | 1 |
| `portfolio holdings` | `GET /v1/user/wallet_holdings` | 2 |
| `portfolio activity` | `GET /v1/user/wallet_activity` | 3 |
| `portfolio stats` | `GET /v1/user/wallet_stats` | 3 |
| `portfolio token-balance` | `GET /v1/user/wallet_token_balance` | 1 |
When a request returns `429`:
- Read `X-RateLimit-Reset` from the response headers. It is a Unix timestamp in seconds that marks when the limit is expected to reset.
- The CLI may wait and retry once automatically when the remaining cooldown is short. If it still fails, stop and tell the user the exact retry time instead of sending more requests.
- For `RATE_LIMIT_EXCEEDED` or `RATE_LIMIT_BANNED`, repeated requests during the cooldown can extend the ban by 5 seconds each time, up to 5 minutes. Do not spam retries.
**First-time setup** (if `GMGN_API_KEY` is not configured):
1. Generate key pair and show the public key to the user:
+18
View File
@@ -71,6 +71,24 @@ Both `GMGN_API_KEY` and `GMGN_PRIVATE_KEY` must be configured in `~/.config/gmgn
- `gmgn-cli` installed globally — if missing, run: `npm install -g gmgn-cli`
## Rate Limit Handling
All swap-related routes used by this skill go through GMGN's leaky-bucket limiter with `rate=10` and `capacity=10`. Sustained throughput is roughly `10 ÷ weight` requests/second, and the max burst is roughly `floor(10 ÷ weight)` when the bucket is full.
| Command | Route | Weight |
|---------|-------|--------|
| `swap` | `POST /v1/trade/swap` | 5 |
| `order quote` | `GET /v1/trade/quote` | 2 |
| `order get` | `GET /v1/trade/query_order` | 1 |
When a request returns `429`:
- Read `X-RateLimit-Reset` from the response headers. It is a Unix timestamp in seconds that marks when the limit is expected to reset.
- `swap` is a real transaction: never loop or auto-submit repeated swap attempts after a `429`. Wait until the reset time, then ask for confirmation again before retrying.
- The CLI may wait and retry once automatically for short cooldowns on read-only commands such as `order quote` and `order get`. If it still fails, stop and tell the user the exact retry time instead of sending more requests.
- For `RATE_LIMIT_EXCEEDED` or `RATE_LIMIT_BANNED`, repeated requests during the cooldown can extend the ban by 5 seconds each time, up to 5 minutes.
- `POST /v1/trade/swap` also has an error-count limiter. Repeatedly triggering the same business error, especially `40003701` (insufficient token balance), can return `ERROR_RATE_LIMIT_BLOCKED`. When this happens, do not retry until the reset time and fix the underlying request first.
**First-time setup** (if credentials are not configured):
1. Generate key pair and show the public key to the user:
+18
View File
@@ -49,6 +49,24 @@ Use the `gmgn-cli` tool to query token information based on the user's request.
- `gmgn-cli` installed globally — if missing, run: `npm install -g gmgn-cli`
- `GMGN_API_KEY` configured in `~/.config/gmgn/.env`
## Rate Limit Handling
All token routes used by this skill go through GMGN's leaky-bucket limiter with `rate=10` and `capacity=10`. Sustained throughput is roughly `10 ÷ weight` requests/second, and the max burst is roughly `floor(10 ÷ weight)` when the bucket is full.
| Command | Route | Weight |
|---------|-------|--------|
| `token info` | `GET /v1/token/info` | 1 |
| `token security` | `GET /v1/token/security` | 1 |
| `token pool` | `GET /v1/token/pool_info` | 1 |
| `token holders` | `GET /v1/market/token_top_holders` | 5 |
| `token traders` | `GET /v1/market/token_top_traders` | 5 |
When a request returns `429`:
- Read `X-RateLimit-Reset` from the response headers. It is a Unix timestamp in seconds that marks when the limit is expected to reset.
- The CLI may wait and retry once automatically when the remaining cooldown is short. If it still fails, stop and tell the user the exact retry time instead of sending more requests.
- For `RATE_LIMIT_EXCEEDED` or `RATE_LIMIT_BANNED`, repeated requests during the cooldown can extend the ban by 5 seconds each time, up to 5 minutes. Do not spam retries.
**First-time setup** (if `GMGN_API_KEY` is not configured):
1. Generate key pair and show the public key to the user:
+16
View File
@@ -63,6 +63,22 @@ Use the `gmgn-cli` tool to query on-chain tracking data based on the user's requ
- `GMGN_API_KEY` configured in `~/.config/gmgn/.env`
- `GMGN_PRIVATE_KEY` required only for `track follow-wallet`; not needed for `track kol` / `track smartmoney`
## Rate Limit Handling
All tracking routes used by this skill go through GMGN's leaky-bucket limiter with `rate=10` and `capacity=10`. Sustained throughput is roughly `10 ÷ weight` requests/second, and the max burst is roughly `floor(10 ÷ weight)` when the bucket is full.
| Command | Route | Weight |
|---------|-------|--------|
| `track follow-wallet` | `GET /v1/trade/follow_wallet` | 3 |
| `track kol` | `GET /v1/user/kol` | 1 |
| `track smartmoney` | `GET /v1/user/smartmoney` | 1 |
When a request returns `429`:
- Read `X-RateLimit-Reset` from the response headers. It is a Unix timestamp in seconds that marks when the limit is expected to reset.
- The CLI may wait and retry once automatically when the remaining cooldown is short. If it still fails, stop and tell the user the exact retry time instead of sending more requests.
- For `RATE_LIMIT_EXCEEDED` or `RATE_LIMIT_BANNED`, repeated requests during the cooldown can extend the ban by 5 seconds each time, up to 5 minutes. Do not spam retries.
**First-time setup** (if `GMGN_API_KEY` is not configured):
1. Generate key pair and show the public key to the user:
+233 -31
View File
@@ -8,6 +8,57 @@
import { buildAuthQuery, buildMessage, detectAlgorithm, sign } from "./signer.js";
const RATE_LIMIT_RETRY_BUFFER_MS = 1000;
const DEFAULT_RATE_LIMIT_AUTO_RETRY_MAX_WAIT_MS = 5000;
interface PreparedRequest {
method: string;
subPath: string;
url: string;
headers: Record<string, string>;
body: string | null;
curlStr: string;
}
interface ResponseEnvelope {
code: number | string;
data?: unknown;
message?: string;
error?: string;
}
interface OpenApiErrorParams {
method: string;
path: string;
status: number;
apiCode?: number | string;
apiError?: string;
apiMessage?: string;
resetAtUnix?: number;
}
class OpenApiError extends Error {
readonly method: string;
readonly path: string;
readonly status: number;
readonly apiCode?: number | string;
readonly apiError?: string;
readonly apiMessage?: string;
readonly resetAtUnix?: number;
constructor(params: OpenApiErrorParams) {
super(buildOpenApiErrorMessage(params));
this.name = "OpenApiError";
this.method = params.method;
this.path = params.path;
this.status = params.status;
this.apiCode = params.apiCode;
this.apiError = params.apiError;
this.apiMessage = params.apiMessage;
this.resetAtUnix = params.resetAtUnix;
}
}
export interface Config {
apiKey: string;
privateKeyPem?: string;
@@ -196,18 +247,24 @@ export class OpenApiClient {
queryExtra: Record<string, string | number | string[]>,
body: unknown = null
): Promise<unknown> {
const { timestamp, client_id } = buildAuthQuery();
const query: Record<string, string | number | string[]> = { ...queryExtra, timestamp, client_id };
const url = buildUrl(`${this.host}${subPath}`, query);
const headers: Record<string, string> = {
"X-APIKEY": this.apiKey,
"Content-Type": "application/json",
};
const bodyStr = body !== null ? JSON.stringify(body) : null;
const curlStr = formatCurl(method, url, headers, bodyStr);
const res = await this.doFetch(method, subPath, url, headers, bodyStr, curlStr);
return this.parseResponse(method, subPath, res, curlStr);
return this.executePreparedRequest(() => {
const { timestamp, client_id } = buildAuthQuery();
const query: Record<string, string | number | string[]> = { ...queryExtra, timestamp, client_id };
const url = buildUrl(`${this.host}${subPath}`, query);
const headers: Record<string, string> = {
"X-APIKEY": this.apiKey,
"Content-Type": "application/json",
};
const bodyStr = body !== null ? JSON.stringify(body) : null;
return {
method,
subPath,
url,
headers,
body: bodyStr,
curlStr: formatCurl(method, url, headers, bodyStr),
};
}, true);
}
private async criticalRequest(
@@ -220,22 +277,65 @@ export class OpenApiClient {
throw new Error("GMGN_PRIVATE_KEY is required for swap/order commands");
}
const { timestamp, client_id } = buildAuthQuery();
const query: Record<string, string | number> = { ...queryExtra, timestamp, client_id };
return this.executePreparedRequest(() => {
const { timestamp, client_id } = buildAuthQuery();
const query: Record<string, string | number> = { ...queryExtra, timestamp, client_id };
const bodyStr = body !== null ? JSON.stringify(body) : "";
const message = buildMessage(subPath, query, bodyStr, timestamp);
const signature = sign(message, this.privateKeyPem!, detectAlgorithm(this.privateKeyPem!));
const bodyStr = body !== null ? JSON.stringify(body) : "";
const message = buildMessage(subPath, query, bodyStr, timestamp);
const signature = sign(message, this.privateKeyPem, detectAlgorithm(this.privateKeyPem));
const url = buildUrl(`${this.host}${subPath}`, query);
const headers: Record<string, string> = {
"X-APIKEY": this.apiKey,
"X-Signature": signature,
"Content-Type": "application/json",
};
return {
method,
subPath,
url,
headers,
body: bodyStr || null,
curlStr: formatCurl(method, url, headers, bodyStr || null),
};
}, method !== "POST");
}
const url = buildUrl(`${this.host}${subPath}`, query);
const headers: Record<string, string> = {
"X-APIKEY": this.apiKey,
"X-Signature": signature,
"Content-Type": "application/json",
};
const curlStr = formatCurl(method, url, headers, bodyStr || null);
const res = await this.doFetch(method, subPath, url, headers, bodyStr || null, curlStr);
return this.parseResponse(method, subPath, res, curlStr);
private async executePreparedRequest(
prepare: () => PreparedRequest,
autoRetryOnRateLimit: boolean
): Promise<unknown> {
const maxAttempts = autoRetryOnRateLimit ? 2 : 1;
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
const request = prepare();
const res = await this.doFetch(
request.method,
request.subPath,
request.url,
request.headers,
request.body,
request.curlStr
);
try {
return this.parseResponse(request.method, request.subPath, res, request.curlStr);
} catch (err) {
const retryDelayMs = getRateLimitRetryDelayMs(err, attempt, maxAttempts, autoRetryOnRateLimit);
if (retryDelayMs == null) {
throw err;
}
if (process.env.GMGN_DEBUG) {
console.error(
`[gmgn-cli] ${request.method} ${request.subPath} hit rate limit, retrying once in ${Math.ceil(retryDelayMs / 1000)}s`
);
}
await sleep(retryDelayMs);
}
}
throw new Error("Unexpected retry loop exit");
}
private async doFetch(
@@ -278,6 +378,8 @@ export class OpenApiClient {
throw new Error(msg);
};
const resetAtUnix = parseRateLimitReset(res.headers.get("x-ratelimit-reset"));
let text!: string;
try {
text = await res.text();
@@ -285,7 +387,7 @@ export class OpenApiClient {
fail(`${method} ${path} failed: HTTP ${res.status} (failed to read response body: ${err})`);
}
let json!: { code: number | string; data?: unknown; message?: string; error?: string };
let json!: ResponseEnvelope;
try {
json = JSON.parse(text);
} catch {
@@ -293,16 +395,116 @@ export class OpenApiClient {
}
if (json.code !== 0) {
fail(
`${method} ${path} failed: HTTP ${res.status} code=${json.code} error=${json.error ?? ""} message=${json.message ?? ""}`,
text
);
if (process.env.GMGN_DEBUG) {
console.error(`${curlStr}\n${formatResponse(res, text)}`);
}
throw new OpenApiError({
method,
path,
status: res.status,
apiCode: json.code,
apiError: json.error,
apiMessage: json.message,
resetAtUnix,
});
}
return json.data;
}
}
function getRateLimitRetryDelayMs(
err: unknown,
attempt: number,
maxAttempts: number,
autoRetryOnRateLimit: boolean
): number | null {
if (!autoRetryOnRateLimit || attempt >= maxAttempts) {
return null;
}
if (!(err instanceof OpenApiError)) {
return null;
}
if (err.apiError !== "RATE_LIMIT_EXCEEDED" && err.apiError !== "RATE_LIMIT_BANNED") {
return null;
}
if (err.resetAtUnix == null) {
return null;
}
const waitMs = Math.max(err.resetAtUnix * 1000 - Date.now(), 0) + RATE_LIMIT_RETRY_BUFFER_MS;
return waitMs <= getAutoRetryMaxWaitMs() ? waitMs : null;
}
function parseRateLimitReset(raw: string | null): number | undefined {
if (raw == null || raw.trim() === "") {
return undefined;
}
const parsed = Number.parseInt(raw, 10);
return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined;
}
function getAutoRetryMaxWaitMs(): number {
const raw = process.env.GMGN_RATE_LIMIT_AUTO_RETRY_MAX_WAIT_MS;
if (!raw) {
return DEFAULT_RATE_LIMIT_AUTO_RETRY_MAX_WAIT_MS;
}
const parsed = Number.parseInt(raw, 10);
return Number.isFinite(parsed) && parsed >= 0 ? parsed : DEFAULT_RATE_LIMIT_AUTO_RETRY_MAX_WAIT_MS;
}
function buildOpenApiErrorMessage(params: OpenApiErrorParams): string {
const parts = [`${params.method} ${params.path} failed: HTTP ${params.status}`];
if (params.apiCode != null) parts.push(`code=${params.apiCode}`);
if (params.apiError) parts.push(`error=${params.apiError}`);
if (params.apiMessage) parts.push(`message=${params.apiMessage}`);
let message = parts.join(" ");
if (params.status !== 429) {
return message;
}
const resetText = params.resetAtUnix != null
? formatRateLimitReset(params.resetAtUnix)
: "an unknown time";
if (params.apiError === "ERROR_RATE_LIMIT_BLOCKED") {
return `${message}. Repeated business errors triggered a temporary block until ${resetText}. Fix the underlying request before retrying.`;
}
if (params.apiError === "RATE_LIMIT_EXCEEDED" || params.apiError === "RATE_LIMIT_BANNED") {
return `${message}. Rate limit resets at ${resetText}. Stop sending requests before then; repeated requests can extend the ban by 5s up to 5 minutes.`;
}
return `${message}. Received HTTP 429; retry after ${resetText}.`;
}
function formatRateLimitReset(resetAtUnix: number): string {
const resetAt = new Date(resetAtUnix * 1000);
const remainingSeconds = Math.max(Math.ceil((resetAt.getTime() - Date.now()) / 1000), 0);
return `${formatLocalTimestamp(resetAt)} (~${remainingSeconds}s remaining)`;
}
function formatLocalTimestamp(date: Date): string {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
const hours = String(date.getHours()).padStart(2, "0");
const minutes = String(date.getMinutes()).padStart(2, "0");
const seconds = String(date.getSeconds()).padStart(2, "0");
const offsetMinutes = -date.getTimezoneOffset();
const sign = offsetMinutes >= 0 ? "+" : "-";
const absOffsetMinutes = Math.abs(offsetMinutes);
const offsetHours = String(Math.floor(absOffsetMinutes / 60)).padStart(2, "0");
const offsetMins = String(absOffsetMinutes % 60).padStart(2, "0");
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds} GMT${sign}${offsetHours}:${offsetMins}`;
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function formatResponse(res: Response, body: string | null): string {
const headerLines = [...res.headers.entries()].map(([k, v]) => ` ${k}: ${v}`).join("\n");
return `[response] HTTP ${res.status}\n${headerLines}\n\n${body ?? "(no body)"}`;