Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 14 additions & 12 deletions docs/error-handling.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,16 +21,16 @@ Error
└─ WebSocketRequestError
```

| Class | Thrown from | Inspect |
| ----------------------- | ------------------------------------------------ | ------------------------------- |
| `ValidationError` | Schema parsing, before any network I/O | `message`, `cause.issues` |
| `FormatError` | `formatPrice` / `formatSize`, before network I/O | `message` |
| `AbstractWalletError` | Signing layer (viem / custom adapter) | `cause` |
| `CanonicalizeError` | `canonicalize()` helper during low-level signing | `message` |
| `ApiRequestError` | Hyperliquid API returned an error response | `message`, `response` |
| `HttpRequestError` | `fetch` failed or returned non-2xx / non-JSON | `response`, `status`, `cause` |
| `HttpRateLimitError` | Server answered 429 (rate limited) | `status`, `retryAfter`, `cause` |
| `WebSocketRequestError` | WebSocket operation failed | `message`, `cause` |
| Class | Thrown from | Inspect |
| ----------------------- | ------------------------------------------------------------------------------ | ------------------------------- |
| `ValidationError` | Schema parsing, before any network I/O | `message`, `cause.issues` |
| `FormatError` | `formatPrice` / `formatSize`, before network I/O | `message` |
| `AbstractWalletError` | Signing layer (viem / custom adapter) | `cause` |
| `CanonicalizeError` | `canonicalize()` helper during low-level signing | `message` |
| `ApiRequestError` | Hyperliquid API returned an error response | `message`, `response` |
| `HttpRequestError` | `fetch` failed, non-2xx / non-JSON, or a 200-OK `{ "type": "error" }` envelope | `response`, `status`, `cause` |
| `HttpRateLimitError` | Server answered 429 (rate limited) | `status`, `retryAfter`, `cause` |
| `WebSocketRequestError` | WebSocket operation failed | `message`, `cause` |

Both transport errors also carry a `request` field with the request payload **as it went over the wire**: a snapshot
of the exact serialization the transport sent, with every `signature`/`signatures` value replaced by
Expand Down Expand Up @@ -154,8 +154,10 @@ try {

### `HttpRequestError`

Thrown by `HttpTransport` when `fetch` itself rejects, or when the server returns a non-2xx / non-JSON response. When
the server did respond, `response` is a [`Response`](https://developer.mozilla.org/en-US/docs/Web/API/Response) object —
Thrown by `HttpTransport` when `fetch` itself rejects, when the server returns a non-2xx / non-JSON response, or when
a 200-OK body is Hyperliquid's `{ "type": "error", "message": "..." }` failure envelope — some server failures arrive
that way instead of as an HTTP error status, and the error's `message` then carries the server's own text. When the
server did respond, `response` is a [`Response`](https://developer.mozilla.org/en-US/docs/Web/API/Response) object —
you can read its status and body — and `status` mirrors the HTTP status code. For network-level failures (DNS,
connection reset, offline), both are `undefined` and the underlying cause is in `cause`.

Expand Down
5 changes: 5 additions & 0 deletions src/api/explorer/_methods/_base/_errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,11 @@ function isErrorResponse(r: unknown): r is { type: "error"; message?: unknown }
/**
* Throws {@linkcode ApiRequestError} if the response is an error; otherwise returns void.
*
* A safety net for transports that hand the envelope through as data: `HttpTransport` already
* rejects 200-OK `{ type: "error" }` bodies at transport level with `HttpRequestError`, so over
* HTTP this never fires — it protects explorer calls made through custom `IRequestTransport`
* implementations without that check.
*
* @param response Raw API response to validate.
*
* @throws {ApiRequestError} If the response contains an error.
Expand Down
58 changes: 46 additions & 12 deletions src/transport/http/mod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@
* rateLimit? ◄─ token bucket wait for the request's weight (opt-in; abort-aware; disabled by default)
* controller ◄─ timeout / user signal / fetchOptions.signal (none allocated when all are absent)
* └─► fetch ┬─► non-OK or non-JSON body ─► HttpRequestError; 429 ─► HttpRateLimitError
* └─► parse JSON ─► T
* └─► parse JSON ┬─► 200-OK `{ type: "error" }` envelope ─► HttpRequestError
* └─► T
* catch: classify by reference ─► finally: cancel timer, detach
* ```
*
Expand Down Expand Up @@ -130,7 +131,8 @@ export const TESTNET_RPC_URL = "https://rpc.hyperliquid-testnet.xyz";
*
* const transport = new HttpTransport();
* try {
* // Throws on a non-OK response, a timeout, an abort, or a network failure.
* // Throws on a non-OK response, a 200-OK `{ type: "error" }` envelope, a timeout,
* // an abort, or a network failure.
* await transport.request("info", { type: "allMids" });
* } catch (error) {
* if (error instanceof HttpRequestError) {
Expand Down Expand Up @@ -304,7 +306,9 @@ export class HttpTransport implements IRequestTransport<"info" | "exchange" | "e
* @param signal {@link https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal | AbortSignal} to cancel the request.
* @return A promise that resolves with the parsed JSON response body.
*
* @throws {HttpRequestError} When the HTTP request fails ({@linkcode HttpRateLimitError} on a 429 response).
* @throws {HttpRequestError} When the HTTP request fails ({@linkcode HttpRateLimitError} on a 429 response) —
* including when a 200-OK body is Hyperliquid's `{ type: "error", message }` failure envelope, in which case
* the error message carries the server's own text.
*
* @example
* ```ts
Expand Down Expand Up @@ -399,17 +403,12 @@ export class HttpTransport implements IRequestTransport<"info" | "exchange" | "e
}

// --- Parse -------------------------------------------------------------
// The try covers ONLY the parse itself: the envelope check below throws HttpRequestError,
// which this catch would otherwise rewrap as an "Invalid JSON response body".
const text = await response.text();
let parsed: unknown;
try {
const parsed = JSON.parse(text);
// Response-size surcharges can only be billed after the fact: debit the bucket so
// later requests wait off the real cost instead of the pre-request estimate.
if (rateLimit !== null && Array.isArray(parsed) && parsed.length > 0) {
snapshot ??= JSON.parse(body); // explorer skipped the pre-send parse (flat weight 40)
const surcharge = responseSurcharge(endpoint, snapshot, parsed);
if (surcharge > 0) rateLimit.charge(surcharge);
}
return parsed;
parsed = JSON.parse(text);
} catch (error) {
throw new HttpRequestError({
response: recreateResponse(response, text),
Expand All @@ -418,6 +417,30 @@ export class HttpTransport implements IRequestTransport<"info" | "exchange" | "e
...errorRequest(body, snapshot),
});
}

// Hyperliquid reports some failures inside a 200 OK: a top-level `{ type: "error", message }`
// envelope (the explorer/rpc failure shape). Surface it here with the server's own message,
// instead of handing the envelope to callers as data — where schema-validated methods would
// fail with a confusing ValidationError and unvalidated ones would return it as a "result".
// Exchange-level failures use a different envelope — `{ status: "err", response }`, handled
// at the API layer — so they still resolve here, as do array bodies and objects that merely
// nest a `type: "error"` somewhere below the top level.
if (isErrorEnvelope(parsed)) {
throw new HttpRequestError({
response: recreateResponse(response, text), // the body stream is already consumed
detail: typeof parsed.message === "string" ? parsed.message : truncate(text),
...errorRequest(body, snapshot),
});
}

// Response-size surcharges can only be billed after the fact: debit the bucket so
// later requests wait off the real cost instead of the pre-request estimate.
if (rateLimit !== null && Array.isArray(parsed) && parsed.length > 0) {
snapshot ??= JSON.parse(body); // explorer skipped the pre-send parse (flat weight 40)
const surcharge = responseSurcharge(endpoint, snapshot, parsed);
if (surcharge > 0) rateLimit.charge(surcharge);
}
return parsed as T;
} catch (error) {
if (error instanceof TransportError) throw error;
if (timeout !== undefined && error === timeout.reason) {
Expand Down Expand Up @@ -463,6 +486,17 @@ function truncate(text: string, limit = 1024): string {
return `${text.slice(0, limit)}… (${text.length} chars total)`;
}

/**
* True when a parsed 200-OK body is Hyperliquid's failure envelope: a top-level non-array object
* with `type === "error"` (the explorer/rpc shape, `{ type: "error", message: string }`). Only
* the top level is inspected — a `type: "error"` nested inside a normal response never matches —
* and `JSON.parse` output aside, the explicit array guard keeps an array with a stray `type`
* property from matching either.
*/
function isErrorEnvelope(body: unknown): body is { type: "error"; message?: unknown } {
return isRecord(body) && !Array.isArray(body) && body.type === "error";
}

// --- Rate-limit weights -------------------------------------------------------
// The tables below mirror the official documentation; keep them in sync with it.
// https://hyperliquid.gitbook.io/hyperliquid-docs/for-developers/api/rate-limits-and-user-limits
Expand Down
65 changes: 65 additions & 0 deletions tests/transport/http/mod.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -245,6 +245,71 @@ describe("HttpTransport", () => {
assertIsError(error.cause, Error, "network error");
});
});

describe("200-OK error envelopes", () => {
// Hyperliquid reports some failures inside a 200 OK as `{ type: "error", message }`.
// The transport must reject those with the server's message, while every other 200-OK
// body — including the exchange endpoint's `{ status: "err" }` envelope, handled at the
// API layer — keeps resolving.
test("{ type: 'error' } envelope rejects with the server's message", async () => {
const envelope = { type: "error", message: "Order must have minimum value of $10." };
mockFetch(() => jsonResponse(envelope));

const transport = new HttpTransport();
const error = await assertRejects(
() => transport.request("info", { type: "allMids" }),
HttpRequestError,
"Order must have minimum value of $10.",
);
assertEquals(error.status, 200);
// The response stays readable (the transport consumed the original body stream).
assert(error.response);
assertEquals(error.response.bodyUsed, false);
assertEquals(await error.response.json(), envelope);
// The request snapshot carries the payload as it went over the wire.
assertEquals(error.request, { type: "allMids" });
});

test("an envelope without a string message falls back to the body text", async () => {
mockFetch(() => jsonResponse({ type: "error", code: 42 }));

const transport = new HttpTransport();
const error = await assertRejects(() => transport.request("info", {}), HttpRequestError);
assert(error.message.includes('{"type":"error","code":42}'));
});

test("a nested type: 'error' does not reject — only the top level matters", async () => {
const body = { data: { type: "error", message: "nested, not an envelope" } };
mockFetch(() => jsonResponse(body));

const transport = new HttpTransport();
assertEquals(await transport.request("info", {}), body);
});

test("an array body never matches the envelope shape", async () => {
const body = [{ type: "error", message: "an item, not an envelope" }];
mockFetch(() => jsonResponse(body));

const transport = new HttpTransport();
assertEquals(await transport.request("info", {}), body);
});

test("normal object bodies still resolve", async () => {
const body = { type: "allMids", mids: { BTC: "50000" } };
mockFetch(() => jsonResponse(body));

const transport = new HttpTransport();
assertEquals(await transport.request("info", {}), body);
});

test("the exchange { status: 'err' } envelope resolves — the API layer owns it", async () => {
const body = { status: "err", response: "Insufficient margin." };
mockFetch(() => jsonResponse(body));

const transport = new HttpTransport();
assertEquals(await transport.request("exchange", { action: { type: "order" } }), body);
});
});
});

describe("fetchOptions", () => {
Expand Down
Loading