From a260eb1ec9ab035b168cd324c95f7cd5dd2de2fe Mon Sep 17 00:00:00 2001 From: kartojal Date: Tue, 22 Sep 2026 19:40:06 +0200 Subject: [PATCH 1/3] feat: prime order metadata caches through the public getters getVersion adopts the fetched order version for the orders that follow and no longer caches error responses. getClobMarketInfo shares one in-flight request per condition. Concurrent orders share a version request, and a forced refresh after an order version mismatch supersedes pending requests so every waiting caller receives the newer version. Order creation rechecks the fee cache after resolving a token so a market fetch that landed meanwhile is not repeated. --- README.md | 21 ++ src/client.ts | 70 ++++- tests/client/orderMetadataCache.test.ts | 327 ++++++++++++++++++++++++ 3 files changed, 414 insertions(+), 4 deletions(-) create mode 100644 tests/client/orderMetadataCache.test.ts diff --git a/README.md b/README.md index 7243407..32639c6 100644 --- a/README.md +++ b/README.md @@ -67,6 +67,27 @@ const resp = await client.createAndPostMarketOrder( console.log(resp); ``` +### Priming order metadata + +The first order on a new client fetches the order version and the market's tick size, +neg-risk flag, and fee details before it can be signed. Both are public GET requests and +need no signer or credentials. To take them off the first trade, run them ahead of time +on the same client instance that will submit orders: + +```ts +await Promise.all([ + client.getVersion(), // adopted as the order version for this client + client.getClobMarketInfo(conditionID), // caches tick size, neg risk, and fees for both outcomes +]); +``` + +Each call refreshes the caches, so repeating it costs one request. A new client instance +starts with empty caches. Order creation reuses a version or market request that is still +in flight instead of starting another one. Orders never require these calls. An order +placed before `getClobMarketInfo` resolves still performs its own token-to-market lookup. +Books, balances, allowances, credentials, and builder fee rates are not cached by these +calls. + ### Authentication The client has two authentication levels: diff --git a/src/client.ts b/src/client.ts index 19d9ed8..6435115 100644 --- a/src/client.ts +++ b/src/client.ts @@ -224,6 +224,10 @@ export class ClobClient { private cachedVersion?: number; + private versionRequest?: Promise; + + private readonly marketInfoRequests = new Map>(); + readonly retryOnError?: boolean; readonly throwOnError?: boolean; @@ -300,8 +304,18 @@ export class ClobClient { }); } + /** + * Fetches the current order version from the server and adopts it for the + * orders this client signs afterwards. An error response yields the default + * version 2 without caching it, so the next order asks the server again. + */ public async getVersion(): Promise { + return this.resolveVersion(true); + } + + private async fetchVersion(): Promise { const response = await this.get(`${this.host}/version`); + if (response && typeof response === "object" && "error" in response) return undefined; // default to v2 return response?.version ?? 2; } @@ -340,7 +354,25 @@ export class ClobClient { return this.get(`${this.host}${GET_MARKET}${conditionID}`); } + /** + * Fetches the market parameters for a condition and caches the tick size, + * neg-risk flag, and fee details of both outcomes for later orders. Every + * call refreshes the caches. Concurrent calls for a condition share one request. + */ public async getClobMarketInfo(conditionID: string): Promise { + const pending = this.marketInfoRequests.get(conditionID); + if (pending) return pending; + + const request = this.fetchClobMarketInfo(conditionID); + this.marketInfoRequests.set(conditionID, request); + try { + return await request; + } finally { + this.marketInfoRequests.delete(conditionID); + } + } + + private async fetchClobMarketInfo(conditionID: string): Promise { const result: MarketDetails = await this.get( `${this.host}${GET_CLOB_MARKET}${conditionID}`, ); @@ -1719,6 +1751,8 @@ export class ClobClient { this.tokenConditionMap[tokenID] = result.condition_id as string; } + // A market fetch may have filled the caches while the token was resolving. + if (tokenID in this.feeInfos) return; await this.getClobMarketInfo(this.tokenConditionMap[tokenID]); } @@ -1767,11 +1801,39 @@ export class ClobClient { return this.cachedVersion; } - // Query API and cache the result - const apiVersion = await this.getVersion(); - this.cachedVersion = apiVersion; + // Join the request already in flight unless a refresh was requested + if (!forceUpdate && this.versionRequest) { + return this.settleVersion(this.versionRequest); + } + + const request = this.fetchVersion(); + this.versionRequest = request; + return this.settleVersion(request); + } + + // Waits for a version request and caches its result. A forced refresh that + // starts while the request is pending replaces it: callers of the replaced + // request receive the newer result and the superseded one is never cached. + private async settleVersion(request: Promise): Promise { + let apiVersion: number | undefined; + try { + apiVersion = await request; + } catch (err) { + if (this.versionRequest === request) this.versionRequest = undefined; + else if (this.versionRequest !== undefined) + return this.settleVersion(this.versionRequest); + throw err; + } + + if (this.versionRequest === request) { + this.versionRequest = undefined; + if (apiVersion !== undefined) this.cachedVersion = apiVersion; + return apiVersion ?? 2; + } - return apiVersion; + // Superseded: defer to the pending refresh, or to what it already cached + if (this.versionRequest !== undefined) return this.settleVersion(this.versionRequest); + return this.cachedVersion ?? apiVersion ?? 2; } private async _retryOnVersionUpdate(retryFunc: () => Promise) { diff --git a/tests/client/orderMetadataCache.test.ts b/tests/client/orderMetadataCache.test.ts new file mode 100644 index 0000000..a75529b --- /dev/null +++ b/tests/client/orderMetadataCache.test.ts @@ -0,0 +1,327 @@ +import { Wallet } from "@ethersproject/wallet"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { ClobClient } from "../../src/client.js"; +import { ApiError } from "../../src/errors.js"; +import type { SignedOrderV1 } from "../../src/order-utils/index.js"; +import { Chain, type MarketDetails, type OrderResponse, Side } from "../../src/types/index.js"; + +const host = "http://localhost:8080"; + +const market: MarketDetails = { + c: "market-a", + t: [ + { t: "a-yes", o: "Yes" }, + { t: "a-no", o: "No" }, + ], + mts: 0.01, + nr: true, + fd: { r: 0.25, e: 2 }, + r: null, +}; + +const signedOrder = { + salt: "1000", + maker: "0xmaker", + signer: "0xsigner", + taker: "0xtaker", + tokenId: "a-yes", + makerAmount: "50", + takerAmount: "100", + side: Side.BUY, + expiration: "0", + nonce: "0", + feeRateBps: "0", + signatureType: 0, + signature: "0xsig", +} as unknown as SignedOrderV1; + +const orderResponse: OrderResponse = { + success: true, + errorMsg: "", + orderID: "0xorder", + status: "matched", + takingAmount: "100", + makingAmount: "50", +}; + +const versionMismatch = { error: "order_version_mismatch" }; + +type Responder = () => unknown; + +const makeClient = (options: { throwOnError?: boolean } = {}) => { + const client = new ClobClient({ + host, + chain: Chain.AMOY, + signer: new Wallet("0x0000000000000000000000000000000000000000000000000000000000000001"), + creds: { key: "key", secret: "c2VjcmV0LXNlY3JldC1zZWNyZXQ=", passphrase: "passphrase" }, + ...options, + }); + // Signing is out of scope. The version handed to the builder is what these tests check. + vi.spyOn(client.orderBuilder, "buildOrder").mockResolvedValue(signedOrder); + vi.spyOn(client.orderBuilder, "buildMarketOrder").mockResolvedValue(signedOrder); + vi.spyOn(client as any, "post").mockResolvedValue(orderResponse); + return client; +}; + +// The private http layer is stubbed so the real order and cache paths run. +const postSpy = (client: ClobClient) => (client as any).post as ReturnType; + +// Routes the private GET helper to canned public metadata. A route responder may +// return a pending promise or throw to simulate slow or failing endpoints. +const mockGet = ( + client: ClobClient, + routes: { version?: Responder; market?: Responder; token?: Responder } = {}, +) => + vi.spyOn(client as any, "get").mockImplementation(async (endpoint: unknown) => { + const url = String(endpoint); + if (url.endsWith("/version")) return routes.version ? routes.version() : { version: 2 }; + if (url.includes("/clob-markets/")) return routes.market ? routes.market() : market; + if (url.includes("/markets-by-token/")) { + return routes.token ? routes.token() : { condition_id: "market-a" }; + } + throw new Error(`unexpected request ${url}`); + }); + +const requests = (get: ReturnType, path: string) => + get.mock.calls.filter(([url]) => String(url).includes(path)).length; + +const buildVersions = (client: ClobClient) => + vi.mocked(client.orderBuilder.buildMarketOrder).mock.calls.map(call => call[2]); + +const marketOrder = (client: ClobClient, tokenID = "a-yes") => + client.createAndPostMarketOrder({ tokenID, amount: 10, side: Side.BUY, price: 0.5 }); + +// Runs the responders in order, then keeps running the last one. +const sequence = (...steps: Responder[]): Responder => { + let index = 0; + return () => { + const step = steps[Math.min(index, steps.length - 1)]; + index += 1; + return step ? step() : undefined; + }; +}; + +const deferred = () => { + let resolve!: (value: T) => void; + const promise = new Promise(res => { + resolve = res; + }); + return { promise, resolve }; +}; + +const settle = () => new Promise(resolve => setTimeout(resolve, 10)); + +afterEach(() => vi.restoreAllMocks()); + +describe("getVersion", () => { + it("adopts the server version for the orders that follow", async () => { + const client = makeClient(); + const get = mockGet(client, { version: () => ({ version: 3 }) }); + + await expect(client.getVersion()).resolves.toBe(3); + await marketOrder(client); + + expect(requests(get, "/version")).toBe(1); + expect(buildVersions(client)).toEqual([3]); + }); + + it("returns the default version for an error response without caching it", async () => { + const client = makeClient(); + const get = mockGet(client, { + version: sequence( + () => ({ error: "unavailable", status: 503 }), + () => ({ version: 3 }), + ), + }); + + await expect(client.getVersion()).resolves.toBe(2); + await marketOrder(client); + + expect(requests(get, "/version")).toBe(2); + expect(buildVersions(client)).toEqual([3]); + }); + + it("propagates ApiError under throwOnError and leaves the cache empty", async () => { + const client = makeClient({ throwOnError: true }); + const failure = new ApiError("unavailable", 503, { error: "unavailable", status: 503 }); + const get = mockGet(client, { + version: sequence( + () => { + throw failure; + }, + () => ({ version: 3 }), + ), + }); + + await expect(client.getVersion()).rejects.toBe(failure); + await marketOrder(client); + + expect(requests(get, "/version")).toBe(2); + expect(buildVersions(client)).toEqual([3]); + }); + + it("does not overwrite a forced refresh with a version fetched earlier", async () => { + const client = makeClient(); + const older = deferred(); + const get = mockGet(client, { + version: sequence( + () => older.promise, + () => ({ version: 3 }), + ), + }); + await client.getClobMarketInfo("market-a"); + + // The order starts a version request. Before it resolves, a mismatch on a + // separately posted order forces a refresh that returns a newer version. + const pending = client.createMarketOrder({ + tokenID: "a-yes", + amount: 10, + side: Side.BUY, + price: 0.5, + }); + await vi.waitFor(() => expect(requests(get, "/version")).toBe(1)); + postSpy(client).mockResolvedValueOnce(versionMismatch); + await client.postOrder(signedOrder); + older.resolve({ version: 2 }); + await pending; + await client.createMarketOrder({ tokenID: "a-no", amount: 10, side: Side.BUY, price: 0.5 }); + + expect(requests(get, "/version")).toBe(2); + expect(buildVersions(client)).toEqual([3, 3]); + }); +}); + +describe("order version resolution", () => { + it("shares one in-flight version request between concurrent orders", async () => { + const client = makeClient(); + const version = deferred(); + const get = mockGet(client, { version: () => version.promise }); + await client.getClobMarketInfo("market-a"); + + const orders = Promise.all([marketOrder(client), marketOrder(client, "a-no")]); + await vi.waitFor(() => expect(requests(get, "/version")).toBe(1)); + await settle(); + version.resolve({ version: 2 }); + await orders; + + expect(requests(get, "/version")).toBe(1); + expect(buildVersions(client)).toEqual([2, 2]); + }); + + it("retries every concurrent order after an order version mismatch", async () => { + const client = makeClient(); + const first = deferred(); + const second = deferred(); + const get = mockGet(client, { + version: sequence( + () => ({ version: 2 }), + () => first.promise, + () => second.promise, + ), + }); + await Promise.all([client.getVersion(), client.getClobMarketInfo("market-a")]); + postSpy(client) + .mockResolvedValueOnce(versionMismatch) + .mockResolvedValueOnce(versionMismatch); + + const orders = Promise.all([marketOrder(client), marketOrder(client, "a-no")]); + // Both forced refreshes are in flight. The first settles while the second is pending. + await vi.waitFor(() => expect(requests(get, "/version")).toBe(3)); + first.resolve({ version: 3 }); + await settle(); + second.resolve({ version: 3 }); + + expect(await orders).toEqual([orderResponse, orderResponse]); + expect(buildVersions(client)).toEqual([2, 2, 3, 3]); + }); +}); + +describe("getClobMarketInfo", () => { + it("caches both outcomes so primed orders need no metadata requests", async () => { + const client = makeClient(); + const get = mockGet(client); + + await Promise.all([client.getVersion(), client.getClobMarketInfo("market-a")]); + for (const token of ["a-yes", "a-no"]) { + expect(client.tickSizes[token]).toBe("0.01"); + expect(client.negRisk[token]).toBe(true); + expect(client.feeInfos[token]).toEqual({ rate: 0.25, exponent: 2 }); + } + + await marketOrder(client); + await marketOrder(client, "a-no"); + await client.createAndPostOrder({ tokenID: "a-yes", price: 0.5, size: 20, side: Side.BUY }); + + expect(get).toHaveBeenCalledTimes(2); + }); + + it("shares one in-flight request per condition and retries after a failure", async () => { + const client = makeClient(); + const details = deferred(); + const get = mockGet(client, { + market: sequence( + () => details.promise, + () => ({ error: "unavailable" }), + () => market, + ), + }); + + const shared = Promise.all([ + client.getClobMarketInfo("market-a"), + client.getClobMarketInfo("market-a"), + ]); + details.resolve(market); + expect(await shared).toEqual([market, market]); + expect(requests(get, "/clob-markets/")).toBe(1); + + await expect(client.getClobMarketInfo("market-a")).rejects.toThrow( + "failed to fetch market info", + ); + await expect(client.getClobMarketInfo("market-a")).resolves.toEqual(market); + expect(requests(get, "/clob-markets/")).toBe(3); + }); + + it("refreshes cached parameters on every call", async () => { + const client = makeClient(); + mockGet(client, { + market: sequence( + () => market, + () => ({ ...market, mts: 0.001, fd: { r: 0.1, e: 1 } }), + ), + }); + + await client.getClobMarketInfo("market-a"); + await client.getClobMarketInfo("market-a"); + + expect(client.tickSizes["a-yes"]).toBe("0.001"); + expect(client.feeInfos["a-no"]).toEqual({ rate: 0.1, exponent: 1 }); + }); + + it("is not fetched again by an order whose token resolved while the market was priming", async () => { + const client = makeClient(); + const condition = deferred(); + const get = mockGet(client, { token: () => condition.promise }); + + const order = marketOrder(client); + await vi.waitFor(() => expect(requests(get, "/markets-by-token/")).toBe(1)); + await client.getClobMarketInfo("market-a"); + condition.resolve({ condition_id: "market-a" }); + await order; + + expect(requests(get, "/clob-markets/")).toBe(1); + }); + + it("keeps caches per client instance", async () => { + const first = makeClient(); + const second = makeClient(); + const firstGet = mockGet(first); + const secondGet = mockGet(second); + + await Promise.all([first.getVersion(), first.getClobMarketInfo("market-a")]); + await marketOrder(second); + + expect(firstGet).toHaveBeenCalledTimes(2); + expect(requests(secondGet, "/version")).toBe(1); + expect(requests(secondGet, "/clob-markets/")).toBe(1); + }); +}); From cbc36384b70039e0f3d4ef5437ca3dc465b11950 Mon Sep 17 00:00:00 2001 From: kartojal Date: Tue, 22 Sep 2026 19:45:46 +0200 Subject: [PATCH 2/3] test: trim order metadata cache tests and rename README section --- README.md | 2 +- tests/client/orderMetadataCache.test.ts | 342 +++++++----------------- 2 files changed, 99 insertions(+), 245 deletions(-) diff --git a/README.md b/README.md index 32639c6..2263b1d 100644 --- a/README.md +++ b/README.md @@ -67,7 +67,7 @@ const resp = await client.createAndPostMarketOrder( console.log(resp); ``` -### Priming order metadata +### Warm up order cache metadata The first order on a new client fetches the order version and the market's tick size, neg-risk flag, and fee details before it can be signed. Both are public GET requests and diff --git a/tests/client/orderMetadataCache.test.ts b/tests/client/orderMetadataCache.test.ts index a75529b..018e561 100644 --- a/tests/client/orderMetadataCache.test.ts +++ b/tests/client/orderMetadataCache.test.ts @@ -3,9 +3,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { ClobClient } from "../../src/client.js"; import { ApiError } from "../../src/errors.js"; import type { SignedOrderV1 } from "../../src/order-utils/index.js"; -import { Chain, type MarketDetails, type OrderResponse, Side } from "../../src/types/index.js"; - -const host = "http://localhost:8080"; +import { Chain, type MarketDetails, Side } from "../../src/types/index.js"; const market: MarketDetails = { c: "market-a", @@ -18,89 +16,47 @@ const market: MarketDetails = { fd: { r: 0.25, e: 2 }, r: null, }; - -const signedOrder = { - salt: "1000", - maker: "0xmaker", - signer: "0xsigner", - taker: "0xtaker", - tokenId: "a-yes", - makerAmount: "50", - takerAmount: "100", - side: Side.BUY, - expiration: "0", - nonce: "0", - feeRateBps: "0", - signatureType: 0, - signature: "0xsig", -} as unknown as SignedOrderV1; - -const orderResponse: OrderResponse = { - success: true, - errorMsg: "", - orderID: "0xorder", - status: "matched", - takingAmount: "100", - makingAmount: "50", -}; - -const versionMismatch = { error: "order_version_mismatch" }; - -type Responder = () => unknown; - -const makeClient = (options: { throwOnError?: boolean } = {}) => { +const mismatch = { error: "order_version_mismatch" }; +const placed = { success: true, orderID: "0xorder", status: "matched" }; +// Signing and posting are stubbed, so only the shape of the order matters. +const signedOrder = { salt: "1", tokenId: "a-yes" } as unknown as SignedOrderV1; + +// Trading client with stubbed signing and http layers. `posts` are the responses of the +// first POSTs, after which every POST reports a placed order. +const makeClient = (posts: unknown[] = [], throwOnError = false) => { const client = new ClobClient({ - host, + host: "http://localhost:8080", chain: Chain.AMOY, signer: new Wallet("0x0000000000000000000000000000000000000000000000000000000000000001"), creds: { key: "key", secret: "c2VjcmV0LXNlY3JldC1zZWNyZXQ=", passphrase: "passphrase" }, - ...options, + throwOnError, }); - // Signing is out of scope. The version handed to the builder is what these tests check. vi.spyOn(client.orderBuilder, "buildOrder").mockResolvedValue(signedOrder); vi.spyOn(client.orderBuilder, "buildMarketOrder").mockResolvedValue(signedOrder); - vi.spyOn(client as any, "post").mockResolvedValue(orderResponse); + const post = vi.spyOn(client as any, "post").mockResolvedValue(placed); + for (const response of posts) post.mockResolvedValueOnce(response); return client; }; -// The private http layer is stubbed so the real order and cache paths run. -const postSpy = (client: ClobClient) => (client as any).post as ReturnType; - -// Routes the private GET helper to canned public metadata. A route responder may -// return a pending promise or throw to simulate slow or failing endpoints. +// Routes the private GET helper to canned metadata. `version` and `details` may return a +// pending promise or throw to model slow or failing endpoints. const mockGet = ( client: ClobClient, - routes: { version?: Responder; market?: Responder; token?: Responder } = {}, + version: () => unknown = () => ({ version: 2 }), + details: () => unknown = () => market, ) => - vi.spyOn(client as any, "get").mockImplementation(async (endpoint: unknown) => { - const url = String(endpoint); - if (url.endsWith("/version")) return routes.version ? routes.version() : { version: 2 }; - if (url.includes("/clob-markets/")) return routes.market ? routes.market() : market; - if (url.includes("/markets-by-token/")) { - return routes.token ? routes.token() : { condition_id: "market-a" }; - } - throw new Error(`unexpected request ${url}`); + vi.spyOn(client as any, "get").mockImplementation(async (url: unknown) => { + if (String(url).endsWith("/version")) return version(); + if (String(url).includes("/clob-markets/")) return details(); + return { condition_id: "market-a" }; }); -const requests = (get: ReturnType, path: string) => +const calls = (get: ReturnType, path: string) => get.mock.calls.filter(([url]) => String(url).includes(path)).length; - -const buildVersions = (client: ClobClient) => +const builtVersions = (client: ClobClient) => vi.mocked(client.orderBuilder.buildMarketOrder).mock.calls.map(call => call[2]); - const marketOrder = (client: ClobClient, tokenID = "a-yes") => client.createAndPostMarketOrder({ tokenID, amount: 10, side: Side.BUY, price: 0.5 }); - -// Runs the responders in order, then keeps running the last one. -const sequence = (...steps: Responder[]): Responder => { - let index = 0; - return () => { - const step = steps[Math.min(index, steps.length - 1)]; - index += 1; - return step ? step() : undefined; - }; -}; - const deferred = () => { let resolve!: (value: T) => void; const promise = new Promise(res => { @@ -108,220 +64,118 @@ const deferred = () => { }); return { promise, resolve }; }; - -const settle = () => new Promise(resolve => setTimeout(resolve, 10)); +const tick = () => new Promise(resolve => setTimeout(resolve, 10)); afterEach(() => vi.restoreAllMocks()); -describe("getVersion", () => { - it("adopts the server version for the orders that follow", async () => { +describe("order metadata caches", () => { + it("primes the version and market caches so orders skip metadata requests", async () => { const client = makeClient(); - const get = mockGet(client, { version: () => ({ version: 3 }) }); + const get = mockGet(client, () => ({ version: 3 })); - await expect(client.getVersion()).resolves.toBe(3); + await Promise.all([client.getVersion(), client.getClobMarketInfo("market-a")]); await marketOrder(client); + await marketOrder(client, "a-no"); + await client.createAndPostOrder({ tokenID: "a-yes", price: 0.5, size: 20, side: Side.BUY }); - expect(requests(get, "/version")).toBe(1); - expect(buildVersions(client)).toEqual([3]); + expect(get).toHaveBeenCalledTimes(2); + expect(builtVersions(client)).toEqual([3, 3]); + expect(client.tickSizes["a-no"]).toBe("0.01"); + expect(client.feeInfos["a-no"]).toEqual({ rate: 0.25, exponent: 2 }); }); - it("returns the default version for an error response without caching it", async () => { + it("refreshes market parameters on every getClobMarketInfo call", async () => { const client = makeClient(); - const get = mockGet(client, { - version: sequence( - () => ({ error: "unavailable", status: 503 }), - () => ({ version: 3 }), - ), - }); + const details = vi + .fn() + .mockReturnValueOnce(market) + .mockReturnValue({ ...market, mts: 0.001 }); + mockGet(client, undefined, details); - await expect(client.getVersion()).resolves.toBe(2); - await marketOrder(client); + await client.getClobMarketInfo("market-a"); + await client.getClobMarketInfo("market-a"); - expect(requests(get, "/version")).toBe(2); - expect(buildVersions(client)).toEqual([3]); + expect(client.tickSizes["a-yes"]).toBe("0.001"); }); - it("propagates ApiError under throwOnError and leaves the cache empty", async () => { - const client = makeClient({ throwOnError: true }); - const failure = new ApiError("unavailable", 503, { error: "unavailable", status: 503 }); - const get = mockGet(client, { - version: sequence( - () => { - throw failure; - }, - () => ({ version: 3 }), - ), - }); + it.each([ + false, + true, + ])("does not cache a failed version fetch (throwOnError=%s)", async throwOnError => { + const client = makeClient([], throwOnError); + const failure = new ApiError("unavailable", 503); + const version = vi.fn().mockReturnValue({ version: 3 }); + if (throwOnError) { + version.mockImplementationOnce(() => { + throw failure; + }); + } else { + version.mockReturnValueOnce({ error: "unavailable", status: 503 }); + } + const get = mockGet(client, version); - await expect(client.getVersion()).rejects.toBe(failure); + const attempt = client.getVersion(); + await (throwOnError + ? expect(attempt).rejects.toBe(failure) + : expect(attempt).resolves.toBe(2)); await marketOrder(client); - expect(requests(get, "/version")).toBe(2); - expect(buildVersions(client)).toEqual([3]); + expect(calls(get, "/version")).toBe(2); + expect(builtVersions(client)).toEqual([3]); }); - it("does not overwrite a forced refresh with a version fetched earlier", async () => { + it("shares in-flight version and market requests between concurrent orders", async () => { const client = makeClient(); - const older = deferred(); - const get = mockGet(client, { - version: sequence( - () => older.promise, - () => ({ version: 3 }), - ), - }); - await client.getClobMarketInfo("market-a"); - - // The order starts a version request. Before it resolves, a mismatch on a - // separately posted order forces a refresh that returns a newer version. - const pending = client.createMarketOrder({ - tokenID: "a-yes", - amount: 10, - side: Side.BUY, - price: 0.5, - }); - await vi.waitFor(() => expect(requests(get, "/version")).toBe(1)); - postSpy(client).mockResolvedValueOnce(versionMismatch); - await client.postOrder(signedOrder); - older.resolve({ version: 2 }); - await pending; - await client.createMarketOrder({ tokenID: "a-no", amount: 10, side: Side.BUY, price: 0.5 }); - - expect(requests(get, "/version")).toBe(2); - expect(buildVersions(client)).toEqual([3, 3]); - }); -}); - -describe("order version resolution", () => { - it("shares one in-flight version request between concurrent orders", async () => { - const client = makeClient(); - const version = deferred(); - const get = mockGet(client, { version: () => version.promise }); - await client.getClobMarketInfo("market-a"); + const get = mockGet(client); - const orders = Promise.all([marketOrder(client), marketOrder(client, "a-no")]); - await vi.waitFor(() => expect(requests(get, "/version")).toBe(1)); - await settle(); - version.resolve({ version: 2 }); - await orders; + await Promise.all([marketOrder(client), marketOrder(client, "a-no")]); - expect(requests(get, "/version")).toBe(1); - expect(buildVersions(client)).toEqual([2, 2]); + expect(calls(get, "/version")).toBe(1); + expect(calls(get, "/clob-markets/")).toBe(1); }); - it("retries every concurrent order after an order version mismatch", async () => { - const client = makeClient(); + it("retries every concurrent order with the refreshed version after a mismatch", async () => { + const client = makeClient([mismatch, mismatch]); const first = deferred(); const second = deferred(); - const get = mockGet(client, { - version: sequence( - () => ({ version: 2 }), - () => first.promise, - () => second.promise, - ), - }); + const version = vi + .fn() + .mockReturnValueOnce({ version: 2 }) + .mockReturnValueOnce(first.promise) + .mockReturnValue(second.promise); + const get = mockGet(client, version); await Promise.all([client.getVersion(), client.getClobMarketInfo("market-a")]); - postSpy(client) - .mockResolvedValueOnce(versionMismatch) - .mockResolvedValueOnce(versionMismatch); const orders = Promise.all([marketOrder(client), marketOrder(client, "a-no")]); // Both forced refreshes are in flight. The first settles while the second is pending. - await vi.waitFor(() => expect(requests(get, "/version")).toBe(3)); + await vi.waitFor(() => expect(calls(get, "/version")).toBe(3)); first.resolve({ version: 3 }); - await settle(); + await tick(); second.resolve({ version: 3 }); - expect(await orders).toEqual([orderResponse, orderResponse]); - expect(buildVersions(client)).toEqual([2, 2, 3, 3]); + expect(await orders).toEqual([placed, placed]); + expect(builtVersions(client)).toEqual([2, 2, 3, 3]); }); -}); - -describe("getClobMarketInfo", () => { - it("caches both outcomes so primed orders need no metadata requests", async () => { - const client = makeClient(); - const get = mockGet(client); - await Promise.all([client.getVersion(), client.getClobMarketInfo("market-a")]); - for (const token of ["a-yes", "a-no"]) { - expect(client.tickSizes[token]).toBe("0.01"); - expect(client.negRisk[token]).toBe(true); - expect(client.feeInfos[token]).toEqual({ rate: 0.25, exponent: 2 }); - } - - await marketOrder(client); - await marketOrder(client, "a-no"); - await client.createAndPostOrder({ tokenID: "a-yes", price: 0.5, size: 20, side: Side.BUY }); - - expect(get).toHaveBeenCalledTimes(2); - }); - - it("shares one in-flight request per condition and retries after a failure", async () => { - const client = makeClient(); - const details = deferred(); - const get = mockGet(client, { - market: sequence( - () => details.promise, - () => ({ error: "unavailable" }), - () => market, - ), - }); - - const shared = Promise.all([ - client.getClobMarketInfo("market-a"), - client.getClobMarketInfo("market-a"), - ]); - details.resolve(market); - expect(await shared).toEqual([market, market]); - expect(requests(get, "/clob-markets/")).toBe(1); - - await expect(client.getClobMarketInfo("market-a")).rejects.toThrow( - "failed to fetch market info", - ); - await expect(client.getClobMarketInfo("market-a")).resolves.toEqual(market); - expect(requests(get, "/clob-markets/")).toBe(3); - }); - - it("refreshes cached parameters on every call", async () => { - const client = makeClient(); - mockGet(client, { - market: sequence( - () => market, - () => ({ ...market, mts: 0.001, fd: { r: 0.1, e: 1 } }), - ), - }); - - await client.getClobMarketInfo("market-a"); - await client.getClobMarketInfo("market-a"); - - expect(client.tickSizes["a-yes"]).toBe("0.001"); - expect(client.feeInfos["a-no"]).toEqual({ rate: 0.1, exponent: 1 }); - }); - - it("is not fetched again by an order whose token resolved while the market was priming", async () => { - const client = makeClient(); - const condition = deferred(); - const get = mockGet(client, { token: () => condition.promise }); - - const order = marketOrder(client); - await vi.waitFor(() => expect(requests(get, "/markets-by-token/")).toBe(1)); + it("hands a pending order the version of a forced refresh that overtook its request", async () => { + const client = makeClient([mismatch]); + const older = deferred(); + const version = vi.fn().mockReturnValueOnce(older.promise).mockReturnValue({ version: 3 }); + const get = mockGet(client, version); await client.getClobMarketInfo("market-a"); - condition.resolve({ condition_id: "market-a" }); - await order; - expect(requests(get, "/clob-markets/")).toBe(1); - }); - - it("keeps caches per client instance", async () => { - const first = makeClient(); - const second = makeClient(); - const firstGet = mockGet(first); - const secondGet = mockGet(second); - - await Promise.all([first.getVersion(), first.getClobMarketInfo("market-a")]); - await marketOrder(second); + const pending = client.createMarketOrder({ + tokenID: "a-yes", + amount: 10, + side: Side.BUY, + price: 0.5, + }); + await vi.waitFor(() => expect(calls(get, "/version")).toBe(1)); + await client.postOrder(signedOrder); // the mismatch forces a refresh that resolves to 3 + older.resolve({ version: 2 }); + await pending; - expect(firstGet).toHaveBeenCalledTimes(2); - expect(requests(secondGet, "/version")).toBe(1); - expect(requests(secondGet, "/clob-markets/")).toBe(1); + expect(builtVersions(client)).toEqual([3]); + expect(calls(get, "/version")).toBe(2); }); }); From cd4ae086df4d632d62c31f5daceab481cf650c70 Mon Sep 17 00:00:00 2001 From: kartojal Date: Tue, 22 Sep 2026 20:01:48 +0200 Subject: [PATCH 3/3] fix: retry on order version mismatch and share version requests Create-and-post retries only when the CLOB rejected the order for a version mismatch, so a version change observed after a placed order no longer posts it a second time. getVersion joins a pending version request instead of superseding it, every version request adopts its result when it settles, and an error response adopts the default version 2 as before. Under throwOnError the mismatch retry now runs once before the ApiError is surfaced. --- README.md | 15 ++-- src/client.ts | 103 +++++++++--------------- tests/client/orderMetadataCache.test.ts | 91 ++++++++++++--------- 3 files changed, 102 insertions(+), 107 deletions(-) diff --git a/README.md b/README.md index 2263b1d..1866479 100644 --- a/README.md +++ b/README.md @@ -81,12 +81,15 @@ await Promise.all([ ]); ``` -Each call refreshes the caches, so repeating it costs one request. A new client instance -starts with empty caches. Order creation reuses a version or market request that is still -in flight instead of starting another one. Orders never require these calls. An order -placed before `getClobMarketInfo` resolves still performs its own token-to-market lookup. -Books, balances, allowances, credentials, and builder fee rates are not cached by these -calls. +Each call asks the server and refreshes the caches, so repeating it costs one request. If +the version request fails, `getVersion` adopts the default version 2 and the first order +corrects it through the mismatch recovery built into order posting. With `throwOnError` +both calls throw `ApiError` instead, so treat a rejected warm-up as a background failure. +A new client instance starts with empty caches. Order creation and `getVersion` reuse a +version or market request that is still in flight instead of starting another one. Orders +never require these calls. An order placed before `getClobMarketInfo` resolves still +performs its own token-to-market lookup. Books, balances, allowances, credentials, and +builder fee rates are not cached by these calls. ### Authentication diff --git a/src/client.ts b/src/client.ts index 6435115..4cfa00b 100644 --- a/src/client.ts +++ b/src/client.ts @@ -224,7 +224,7 @@ export class ClobClient { private cachedVersion?: number; - private versionRequest?: Promise; + private versionRequest?: Promise; private readonly marketInfoRequests = new Map>(); @@ -306,18 +306,28 @@ export class ClobClient { /** * Fetches the current order version from the server and adopts it for the - * orders this client signs afterwards. An error response yields the default - * version 2 without caching it, so the next order asks the server again. + * orders this client signs afterwards. Joins a version request that is + * already in flight. An error response yields the default version 2, which + * the first order corrects through the order version mismatch recovery. + * Throws ApiError when the client was created with throwOnError. */ public async getVersion(): Promise { - return this.resolveVersion(true); - } - - private async fetchVersion(): Promise { - const response = await this.get(`${this.host}/version`); - if (response && typeof response === "object" && "error" in response) return undefined; - // default to v2 - return response?.version ?? 2; + return this.versionRequest ?? this.startVersionRequest(); + } + + private startVersionRequest(): Promise { + const request = this.get(`${this.host}/version`) + .then(response => { + // default to v2 + const version: number = response?.version ?? 2; + this.cachedVersion = version; + return version; + }) + .finally(() => { + if (this.versionRequest === request) this.versionRequest = undefined; + }); + this.versionRequest = request; + return request; } public async getServerTime(): Promise { @@ -1073,14 +1083,10 @@ export class ClobClient { postOnly = false, deferExec = false, ): Promise { - let postOrderResponse: OrderResponse | undefined; - - await this._retryOnVersionUpdate(async () => { + return this._retryOnVersionUpdate(async () => { const order = await this.createOrder(userOrder, options); - postOrderResponse = await this.postOrder(order, orderType, postOnly, deferExec); + return this.postOrder(order, orderType, postOnly, deferExec); }); - - return postOrderResponse as OrderResponse; } public async createAndPostMarketOrder( @@ -1089,14 +1095,10 @@ export class ClobClient { orderType: T = OrderType.FOK as T, deferExec = false, ): Promise { - let postOrderMarketResponse: OrderResponse | undefined; - - await this._retryOnVersionUpdate(async () => { + return this._retryOnVersionUpdate(async () => { const order = await this.createMarketOrder(userMarketOrder, options); - postOrderMarketResponse = await this.postOrder(order, orderType, false, deferExec); + return this.postOrder(order, orderType, false, deferExec); }); - - return postOrderMarketResponse as OrderResponse; } public async getOpenOrders( @@ -1801,53 +1803,28 @@ export class ClobClient { return this.cachedVersion; } - // Join the request already in flight unless a refresh was requested - if (!forceUpdate && this.versionRequest) { - return this.settleVersion(this.versionRequest); - } - - const request = this.fetchVersion(); - this.versionRequest = request; - return this.settleVersion(request); + // Concurrent orders share the request in flight. A refresh after an order + // version mismatch asks the server again, since a pending answer may predate + // the change. Every request adopts its result when it settles. + if (!forceUpdate && this.versionRequest) return this.versionRequest; + return this.startVersionRequest(); } - // Waits for a version request and caches its result. A forced refresh that - // starts while the request is pending replaces it: callers of the replaced - // request receive the newer result and the superseded one is never cached. - private async settleVersion(request: Promise): Promise { - let apiVersion: number | undefined; + // Runs a create-and-post attempt and repeats it once when the CLOB rejected + // the order for a version mismatch. postOrder refreshes the cached version on + // a mismatch, so the second attempt signs with the current version. + private async _retryOnVersionUpdate(attempt: () => Promise): Promise { try { - apiVersion = await request; + const response = await attempt(); + if (!this._isOrderVersionMismatch(response as ClobErrorResponseBody)) return response; } catch (err) { - if (this.versionRequest === request) this.versionRequest = undefined; - else if (this.versionRequest !== undefined) - return this.settleVersion(this.versionRequest); - throw err; - } - - if (this.versionRequest === request) { - this.versionRequest = undefined; - if (apiVersion !== undefined) this.cachedVersion = apiVersion; - return apiVersion ?? 2; - } - - // Superseded: defer to the pending refresh, or to what it already cached - if (this.versionRequest !== undefined) return this.settleVersion(this.versionRequest); - return this.cachedVersion ?? apiVersion ?? 2; - } - - private async _retryOnVersionUpdate(retryFunc: () => Promise) { - const version = await this.resolveVersion(); - - for (let attempt = 0; attempt < 2; attempt++) { - await retryFunc(); - - // no need to retry if version is unchanged - if (version === (await this.resolveVersion())) break; + const data = err instanceof ApiError ? (err.data as ClobErrorResponseBody) : undefined; + if (!this._isOrderVersionMismatch(data)) throw err; } + return attempt(); } - private _isOrderVersionMismatch(resp: ClobErrorResponseBody) { + private _isOrderVersionMismatch(resp?: ClobErrorResponseBody) { const error = resp?.error; if (!error) return false; const message = typeof error === "string" ? error : JSON.stringify(error); diff --git a/tests/client/orderMetadataCache.test.ts b/tests/client/orderMetadataCache.test.ts index 018e561..8309970 100644 --- a/tests/client/orderMetadataCache.test.ts +++ b/tests/client/orderMetadataCache.test.ts @@ -53,6 +53,8 @@ const mockGet = ( const calls = (get: ReturnType, path: string) => get.mock.calls.filter(([url]) => String(url).includes(path)).length; +const posts = (client: ClobClient) => + ((client as any).post as ReturnType).mock.calls.length; const builtVersions = (client: ClobClient) => vi.mocked(client.orderBuilder.buildMarketOrder).mock.calls.map(call => call[2]); const marketOrder = (client: ClobClient, tokenID = "a-yes") => @@ -98,40 +100,50 @@ describe("order metadata caches", () => { expect(client.tickSizes["a-yes"]).toBe("0.001"); }); - it.each([ - false, - true, - ])("does not cache a failed version fetch (throwOnError=%s)", async throwOnError => { - const client = makeClient([], throwOnError); + it("adopts the default version when the version request fails and posts the order once", async () => { + const client = makeClient(); + const version = vi + .fn() + .mockReturnValueOnce({ error: "unavailable", status: 503 }) + .mockReturnValue({ version: 3 }); + const get = mockGet(client, version); + + await expect(client.getVersion()).resolves.toBe(2); + await expect(marketOrder(client)).resolves.toEqual(placed); + + expect(calls(get, "/version")).toBe(1); + expect(builtVersions(client)).toEqual([2]); + expect(posts(client)).toBe(1); + }); + + it("propagates ApiError from getVersion under throwOnError and leaves the cache empty", async () => { + const client = makeClient([], true); const failure = new ApiError("unavailable", 503); - const version = vi.fn().mockReturnValue({ version: 3 }); - if (throwOnError) { - version.mockImplementationOnce(() => { - throw failure; - }); - } else { - version.mockReturnValueOnce({ error: "unavailable", status: 503 }); - } + const version = vi.fn().mockRejectedValueOnce(failure).mockReturnValue({ version: 3 }); const get = mockGet(client, version); - const attempt = client.getVersion(); - await (throwOnError - ? expect(attempt).rejects.toBe(failure) - : expect(attempt).resolves.toBe(2)); + await expect(client.getVersion()).rejects.toBe(failure); await marketOrder(client); expect(calls(get, "/version")).toBe(2); expect(builtVersions(client)).toEqual([3]); }); - it("shares in-flight version and market requests between concurrent orders", async () => { + it("shares one in-flight version request between concurrent orders and getVersion", async () => { const client = makeClient(); - const get = mockGet(client); + const version = deferred(); + const get = mockGet(client, () => version.promise); - await Promise.all([marketOrder(client), marketOrder(client, "a-no")]); + const orders = Promise.all([marketOrder(client), marketOrder(client, "a-no")]); + await vi.waitFor(() => expect(calls(get, "/version")).toBe(1)); + const poll = client.getVersion(); + version.resolve({ version: 3 }); + await expect(poll).resolves.toBe(3); + await orders; expect(calls(get, "/version")).toBe(1); expect(calls(get, "/clob-markets/")).toBe(1); + expect(builtVersions(client)).toEqual([3, 3]); }); it("retries every concurrent order with the refreshed version after a mismatch", async () => { @@ -157,25 +169,28 @@ describe("order metadata caches", () => { expect(builtVersions(client)).toEqual([2, 2, 3, 3]); }); - it("hands a pending order the version of a forced refresh that overtook its request", async () => { - const client = makeClient([mismatch]); - const older = deferred(); - const version = vi.fn().mockReturnValueOnce(older.promise).mockReturnValue({ version: 3 }); - const get = mockGet(client, version); - await client.getClobMarketInfo("market-a"); + it("retries once after a mismatch under throwOnError", async () => { + const client = makeClient([mismatch], true); + mockGet( + client, + vi.fn().mockReturnValueOnce({ version: 2 }).mockReturnValue({ version: 3 }), + ); - const pending = client.createMarketOrder({ - tokenID: "a-yes", - amount: 10, - side: Side.BUY, - price: 0.5, - }); - await vi.waitFor(() => expect(calls(get, "/version")).toBe(1)); - await client.postOrder(signedOrder); // the mismatch forces a refresh that resolves to 3 - older.resolve({ version: 2 }); - await pending; + await expect(marketOrder(client)).resolves.toEqual(placed); - expect(builtVersions(client)).toEqual([3]); - expect(calls(get, "/version")).toBe(2); + expect(builtVersions(client)).toEqual([2, 3]); + }); + + it("returns the mismatch response when the retry is rejected again", async () => { + const client = makeClient([mismatch, mismatch]); + mockGet( + client, + vi.fn().mockReturnValueOnce({ version: 2 }).mockReturnValue({ version: 3 }), + ); + + await expect(marketOrder(client)).resolves.toEqual(mismatch); + + expect(posts(client)).toBe(2); + expect(builtVersions(client)).toEqual([2, 3]); }); });