Skip to content

client: fetchBuilderFeeRates lacks 404 handling — prepareMarketOrder with unregistered builderCode crashes instead of degrading #74

@Nexory

Description

@Nexory

fetchBuilderFeeRates in packages/client/src/actions/clob.ts:398-409 doesn't translate or guard the GET /fees/builder-fees/<code> 404 case, and fetchBuilderTakerFeeRate in packages/client/src/actions/orders/market.ts:222-232 calls it unconditionally whenever builderCode is set on a market order. The result is that any prepareMarketOrder / prepareMarketBuyOrder / prepareMarketSellOrder call carrying an unregistered (or stale) builder code throws on the fee fetch and never reaches order construction.

Current code

// packages/client/src/actions/clob.ts:398-409
export async function fetchBuilderFeeRates(
  client: BaseClient,
  request: FetchBuilderFeeRatesRequest,
): Promise<BuilderFeeRates> {
  const params = parseUserInput(request, FetchBuilderFeeRatesRequestSchema);

  return unwrap(
    client.clob
      .get(`/fees/builder-fees/${params.builderCode}`)
      .andThen(validateWith(FetchBuilderFeeRatesResponseSchema)),
  );
}

// packages/client/src/actions/orders/market.ts:222-232
async function fetchBuilderTakerFeeRate(
  client: BaseSecureClient,
  builderCode: BuilderCode | undefined,
): Promise<number> {
  if (builderCode === undefined) {
    return 0;
  }

  const builderFees = await fetchBuilderFeeRates(client, { builderCode });

  return builderFees.taker;
}

The unwrap call surfaces any non-2xx response as a RequestRejectedError, which is in the public error union — but fetchBuilderTakerFeeRate does not catch it, and the public prepareMarketOrder path treats every error here as fatal.

Live server behavior

The endpoint returns 404 Not Found with body {"error":"builder code not found"} for any code the server doesn't have registered:

$ curl -sI -H 'Origin: https://example.com' \
    'https://clob.polymarket.com/fees/builder-fees/0xabcdef…cdef'
HTTP/2 404
access-control-allow-origin: *

CORS is fine — the failure is purely the SDK propagating the 404 as an unwrap throw and killing the request pipeline. (Aside: the original V2-transitional report at Polymarket/clob-client-v2#51 claimed CORS was missing on this route; that part of the report is incorrect, the route does set ACAO: *. The behavioral bug — 404 throw → killed createOrder — is real.)

Same shape on clob-client-v2

This is structurally the same as Polymarket/clob-client-v2#51, with PR #72 by @Cassxbt proposing a silent try/catch in ensureBuilderFeeRateCached. That PR has been open since 2026-05-21 without maintainer engagement; the underlying behavior question (silent skip vs typed error) seems unresolved.

Design question

Three approaches I can see, ranked by how invasive they are:

  1. Silent skip (@Cassxbt's clob-v2 PR docs: clarify order book and subscription types #72 approach): wrap the call in try/catch, return a zero BuilderFeeRates. Lets the order proceed but drops builder attribution silently — bad UX if the caller actually intended attribution.
  2. Typed error mapping: ResultAsync.mapErr on the request pipeline to translate RequestRejectedError.status === 404 into a new UnknownBuilderCodeError. Add it to FetchBuilderFeeRatesError and to prepareMarketOrderError's union. Caller has to decide policy.
  3. Server-side change: the route returns 404 only when the builder code is unknown. If the platform also wants to surface "registered but not active" or similar states, distinguish them.

I'd lean toward (2) — it preserves information (the caller can tell "this builder code isn't registered" apart from "transport error / 500"), follows the SDK's existing pattern of mapping HTTP failures into typed errors (packages/client/src/errors.ts), and matches the AGENTS.md guidance about using mapErr over try/catch around unwrap. But this is a design call and could go differently.

Impact

Limit orders are unaffected (limit.ts doesn't call fetchBuilderTakerFeeRate). The break is scoped to market orders with builder attribution — but those exist precisely because builder attribution is the documented V2 monetization path. A new builder who pastes their builder code from an older test environment, or whose code hasn't yet propagated to the fee table, gets a confusing RequestRejectedError from a market-order call rather than from the builder-fee call.

Cross-SDK

Same pattern in py-sdk: src/polymarket/_internal/actions/orders/market_data.py:75-83 (fetch_builder_fee_rates / fetch_builder_fee_rates_sync) raises on 404, callers in _internal/actions/orders/market.py:185-217 re-raise. Filing companion issue.

Happy to PR once you've picked a direction.

Metadata

Metadata

Assignees

No one assigned

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions