Skip to content
Open
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
6 changes: 6 additions & 0 deletions .changeset/dev-535-perps-cancel-retries.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@polymarket/bindings': minor
'@polymarket/client': minor
---

Type known Perps cancellation rejections while preserving unrecognized identifiers, and retry transient `order_in_flight` results with configurable bounded backoff.
36 changes: 36 additions & 0 deletions packages/bindings/src/perps/orders.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest';
import {
PerpsAccountFillSchema,
PerpsCancelOrderResultSchema,
PerpsKnownCancelOrderErrorCode,
PerpsOrderSchema,
PerpsOrderUpdateSchema,
PerpsPostOrderAckSchema,
Expand Down Expand Up @@ -172,6 +173,41 @@ describe('PerpsCancelOrderResultSchema', () => {
expect(result.orderId).toBeUndefined();
});

it.each(
Object.values(PerpsKnownCancelOrderErrorCode),
)('types the %s rejection identifier', (error) => {
const result = PerpsCancelOrderResultSchema.parse({ error, status: 'err' });

expect(result).toEqual({
clientOrderId: undefined,
error,
orderId: undefined,
status: 'err',
});
});

it('preserves cancellation rejection identifiers introduced after release', () => {
const result = PerpsCancelOrderResultSchema.parse({
error: 'unknown_error_code_18',
oid: 123,
status: 'err',
});

expect(result).toEqual({
clientOrderId: undefined,
error: 'unknown_error_code_18',
orderId: 123,
status: 'err',
});
});

it.each([
{ status: 'err' },
{ error: '', status: 'err' },
])('rejects a cancellation rejection without an identifier: %j', (value) => {
expect(() => PerpsCancelOrderResultSchema.parse(value)).toThrow();
});

it('normalizes TP/SL metadata', () => {
const order = PerpsOrderSchema.parse({
order_id: 123,
Expand Down
58 changes: 51 additions & 7 deletions packages/bindings/src/perps/orders.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,43 @@ export const PerpsOrderStatusSchema = z.enum(PerpsOrderStatus);
*/
export const PerpsCommandStatusSchema = z.enum(['ok', 'err']);

/**
* Known rejection identifiers returned when a Perps order cancellation fails.
*
* The service evolves this set independently of released clients, so
* cancellation parsing accepts unknown identifiers as plain strings; see
* {@link PerpsCancelOrderErrorCode}.
*
* @experimental This API may change in a breaking way in any release, including patch releases.
*/
export enum PerpsKnownCancelOrderErrorCode {
OrderUnknown = 'order_unknown',
OrderNotInOrderbook = 'order_not_in_orderbook',
OrderInFlight = 'order_in_flight',
OrderNotPendingEngine = 'order_not_pending_engine',
OrderNotFound = 'order_not_found',
}

/**
* A Perps order cancellation rejection identifier. Known identifiers are
* enumerated in {@link PerpsKnownCancelOrderErrorCode}; newly introduced
* identifiers flow through as plain strings so they can be handled before a
* client release that enumerates them.
*
* @experimental This API may change in a breaking way in any release, including patch releases.
*/
export type PerpsCancelOrderErrorCode =
| PerpsKnownCancelOrderErrorCode
| (string & {});

/**
* @experimental This API may change in a breaking way in any release, including patch releases.
*/
export const PerpsCancelOrderErrorCodeSchema = z
.string()
.min(1)
.transform((value): PerpsCancelOrderErrorCode => value);

const PerpsAckErrorSchema = z
.string()
.min(1)
Expand Down Expand Up @@ -150,17 +187,24 @@ export type PerpsPostOrderAck = z.infer<typeof PerpsPostOrderAckSchema>;
* @experimental This API may change in a breaking way in any release, including patch releases.
*/
export const PerpsCancelOrderResultSchema = z
.object({
status: PerpsCommandStatusSchema,
oid: PerpsOrderIdSchema.optional(),
coid: PerpsClientOrderIdSchema.optional(),
error: z.string().optional(),
})
.discriminatedUnion('status', [
z.object({
status: z.literal('ok'),
oid: PerpsOrderIdSchema.optional(),
coid: PerpsClientOrderIdSchema.optional(),
}),
z.object({
status: z.literal('err'),
error: PerpsCancelOrderErrorCodeSchema,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[blocking] Closing this field to five values turns any other rejection identifier into a thrown error that discards the rest of the batch.

The engine renders a code its build predates as a literal unknown_error_code_<n> string — Error::Unknown(u16) carries #[strum(to_string = "unknown_error_code_{0}")] (perpetuals engine/platform/src/errors.rs:207-209) — and the cancel ack goes straight through it via cancel_rejected_err(err) -> cancel_rejected(err.to_string(), ..) (apps/gateway/common/result.rs), whose own comment says why: "to_string() (not as_str()) so an unrecognized wire code surfaces as unknown_error_code_<n>". docs/getting-started/errors.mdx also documents a WS request-level rejection of cancel-orders / cancel-orders-coid as [{ "status": "err", "error": "invalid_request" }], and lists cancel-reachable identifiers outside this set (account_not_found, proxy_expired, account_liquidating).

The damage isn't confined to the offending item. cancelPerpsOrders parses with z.array(PerpsCancelOrderResultSchema), so one non-member fails the whole array, and PerpsSession.#handleResponse (session.ts:1075-1083) routes a schema failure through errorAckFrom, which recurses into the array and rejects the command with RequestRejectedError(<first err string>). So

[{ "status": "ok", "oid": 1 }, { "status": "err", "error": "unknown_error_code_18", "oid": 2 }]

used to return two results and now throws — the caller can't tell that order 1 was cancelled. That is the worst information to lose on a cancel path.

The repo already has the pattern for this: PerpsWithdrawalStatus (perps/common.ts:224-239) pairs a PerpsKnown* enum with a Known | (string & {}) alias and z.string().transform(...), documenting that the service evolves the set independently of released clients. The same shape here keeps result.error === PerpsCancelOrderErrorCode.OrderInFlight working in retryPerpsOrderCancellations while letting new codes through.

orders.test.ts:192 codifies the strict behavior, so I read this as deliberate rather than an oversight — but I don't think the wire contract supports it.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Already addressed in 8cd56f7. PerpsCancelOrderErrorCode = PerpsKnownCancelOrderErrorCode | (string & {}) with z.string().min(1) matches the PerpsWithdrawalStatus precedent exactly, and orders.test.ts now pins unknown_error_code_18 flowing through with its oid. A mixed [ok, unknown_code] array no longer collapses the whole command.

oid: PerpsOrderIdSchema.optional(),
coid: PerpsClientOrderIdSchema.optional(),
}),
])
.transform((ack) => {
if (ack.status === 'err') {
return {
status: 'err' as const,
error: perpsAckError(ack.error),
error: ack.error,
orderId: ack.oid,
clientOrderId: ack.coid,
};
Expand Down
23 changes: 22 additions & 1 deletion packages/client/src/actions/perps.test-d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,10 @@ import type {
ListPerpsTradesRequest,
ListPerpsWithdrawalsRequest,
OpenPerpsSessionRequest,
PerpsCancelOptions,
PerpsCancelOrderErrorCode,
PerpsCancelOrderResult,
PerpsCancelRetryOptions,
PerpsSessionAccountError,
PerpsSessionLifecycleError,
PerpsSessionTradingError,
Expand All @@ -34,7 +38,11 @@ import type {
UpdatePerpsMarginRequest,
WithdrawFromPerpsRequest,
} from '../index';
import { FetchPerpsTickerError, UpdatePerpsMarginError } from '../index';
import {
FetchPerpsTickerError,
PerpsKnownCancelOrderErrorCode,
UpdatePerpsMarginError,
} from '../index';
import type { FetchPerpsInstrumentsRequest } from './perps';

describe('FetchPerpsInstrumentsRequest', () => {
Expand Down Expand Up @@ -89,6 +97,8 @@ describe('public Perps exports', () => {
CancelAllPerpsOrdersRequest,
CancelPerpsOrderRequest,
CancelPerpsOrdersRequest,
PerpsCancelOptions,
PerpsCancelRetryOptions,
UpdatePerpsLeverageRequest,
UpdatePerpsMarginRequest,
];
Expand All @@ -107,4 +117,15 @@ describe('public Perps exports', () => {
void FetchPerpsTickerError;
void UpdatePerpsMarginError;
});

it('exports known cancel rejections and narrows rejected results', () => {
const result = undefined as unknown as PerpsCancelOrderResult;

if (result.status === 'err') {
expectTypeOf(result.error).toEqualTypeOf<PerpsCancelOrderErrorCode>();
} else {
expectTypeOf(result.error).toEqualTypeOf<undefined>();
}
void PerpsKnownCancelOrderErrorCode.OrderInFlight;
});
});
2 changes: 2 additions & 0 deletions packages/client/src/actions/perps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,9 @@ export type {
ListPerpsWithdrawalsRequest,
MarkPerpsNotificationsReadRequest,
PerpsAutoCancelStatus,
PerpsCancelOptions,
PerpsCancelOrderResult,
PerpsCancelRetryOptions,
PerpsOrderRequest,
PerpsPlacedTpSlOrder,
PerpsPlacedTpSlOrders,
Expand Down
2 changes: 2 additions & 0 deletions packages/client/src/decorators/perps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,9 @@ export type {
OpenPerpsSessionRequest,
PerpsAutoCancelStatus,
PerpsBookDepth,
PerpsCancelOptions,
PerpsCancelOrderResult,
PerpsCancelRetryOptions,
PerpsOrderRequest,
PerpsPlacedTpSlOrder,
PerpsPlacedTpSlOrders,
Expand Down
17 changes: 17 additions & 0 deletions packages/client/src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,23 @@ export class TimeoutError extends PolymarketError {
}
}

/**
* Error thrown when an operation is interrupted through an abort signal.
*/
export class OperationAbortedError extends PolymarketError {
override name = 'OperationAbortedError' as const;

constructor(message: string, options: ErrorOptions = {}) {
super(message, options);
}

static fromReason(reason: unknown): OperationAbortedError {
return new OperationAbortedError('Operation was aborted.', {
cause: reason,
});
}
}

/**
* Error thrown when a submitted transaction reaches a terminal failure state.
*/
Expand Down
1 change: 1 addition & 0 deletions packages/client/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ export {
PerpsInstrumentType,
PerpsInternalTransferDirection,
PerpsKlineInterval,
PerpsKnownCancelOrderErrorCode,
PerpsKnownWithdrawalStatus,
PerpsMarginType,
PerpsNotificationOrderType,
Expand Down
Loading