Skip to content
Open
Show file tree
Hide file tree
Changes from 3 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
65 changes: 58 additions & 7 deletions packages/bindings/src/perps/orders.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,50 @@ 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',
}

/**
* Runtime values for known Perps order cancellation rejection identifiers.
*
* @experimental This API may change in a breaking way in any release, including patch releases.
*/
export const PerpsCancelOrderErrorCode = PerpsKnownCancelOrderErrorCode;

/**
* 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 +194,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
24 changes: 23 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,9 @@ import type {
ListPerpsTradesRequest,
ListPerpsWithdrawalsRequest,
OpenPerpsSessionRequest,
PerpsCancelOptions,
PerpsCancelOrderResult,
PerpsCancelRetryOptions,
PerpsSessionAccountError,
PerpsSessionLifecycleError,
PerpsSessionTradingError,
Expand All @@ -34,7 +37,12 @@ import type {
UpdatePerpsMarginRequest,
WithdrawFromPerpsRequest,
} from '../index';
import { FetchPerpsTickerError, UpdatePerpsMarginError } from '../index';
import {
FetchPerpsTickerError,
PerpsCancelOrderErrorCode,
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,16 @@ 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 PerpsCancelOrderErrorCode.OrderInFlight;
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
2 changes: 2 additions & 0 deletions packages/client/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,13 @@ export type * from '@polymarket/bindings/gamma';
export { WalletType } from '@polymarket/bindings/gamma';
export type * from '@polymarket/bindings/perps';
export {
PerpsCancelOrderErrorCode,
PerpsDepositStatus,
PerpsInstrumentCategory,
PerpsInstrumentType,
PerpsInternalTransferDirection,
PerpsKlineInterval,
PerpsKnownCancelOrderErrorCode,
PerpsKnownWithdrawalStatus,
PerpsMarginType,
PerpsNotificationOrderType,
Expand Down
Loading