From 2e474e4ad9fe99a7f9a438cea682bc9148cf8eaa Mon Sep 17 00:00:00 2001 From: Joe Blau Date: Mon, 3 Aug 2026 21:37:20 -0700 Subject: [PATCH] fix(transport): reject 200-OK { type: "error" } envelopes with HttpRequestError Hyperliquid reports some failures inside a 200 OK response as a top-level { type: "error", message } envelope (the explorer/rpc failure shape). HttpTransport.request() validated only the HTTP status and Content-Type, so such envelopes were returned to callers as data: unvalidated info methods silently yielded the envelope as a "result", and schema-validated methods failed with a confusing ValidationError instead of the server's message. Upstream @nktkas/hyperliquid threw on these envelopes; this fork had dropped the check. request() now checks the parsed body after JSON.parse: a non-null, non-array object with type === "error" throws HttpRequestError carrying the server's message string when present (truncated body text otherwise), a readable recreated Response, and the redacted request snapshot. The invalid-JSON try block is narrowed to the parse alone so the new throw is not rewrapped as "Invalid JSON response body". Only top-level type === "error" envelopes throw: array bodies, normal objects, nested type fields, and the exchange endpoint's { status: "err" } envelope (owned by the API layer) still resolve at transport level. The explorer API layer's assertSuccessResponse remains as a safety net for custom transports and is documented as such. Docs: module diagram, class/method JSDoc, and error-handling.md updated to enumerate the new failure case. Fixes #91 Co-Authored-By: Claude Opus 5 (1M context) --- docs/error-handling.md | 26 +++++---- src/api/explorer/_methods/_base/_errors.ts | 5 ++ src/transport/http/mod.ts | 58 +++++++++++++++---- tests/transport/http/mod.test.ts | 65 ++++++++++++++++++++++ 4 files changed, 130 insertions(+), 24 deletions(-) diff --git a/docs/error-handling.md b/docs/error-handling.md index 7bbc5048..7d712726 100644 --- a/docs/error-handling.md +++ b/docs/error-handling.md @@ -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 @@ -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`. diff --git a/src/api/explorer/_methods/_base/_errors.ts b/src/api/explorer/_methods/_base/_errors.ts index 35510f1f..7ec37b26 100644 --- a/src/api/explorer/_methods/_base/_errors.ts +++ b/src/api/explorer/_methods/_base/_errors.ts @@ -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. diff --git a/src/transport/http/mod.ts b/src/transport/http/mod.ts index 458b2dce..67a43061 100644 --- a/src/transport/http/mod.ts +++ b/src/transport/http/mod.ts @@ -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 * ``` * @@ -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) { @@ -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 @@ -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), @@ -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) { @@ -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 diff --git a/tests/transport/http/mod.test.ts b/tests/transport/http/mod.test.ts index 6e26fc2b..8af1e224 100644 --- a/tests/transport/http/mod.test.ts +++ b/tests/transport/http/mod.test.ts @@ -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", () => {