diff --git a/src/polymarket/__init__.py b/src/polymarket/__init__.py index e857d8b8..c4e12694 100644 --- a/src/polymarket/__init__.py +++ b/src/polymarket/__init__.py @@ -106,6 +106,11 @@ OrderType, PerpsBalance, PerpsBook, + PerpsCancelOrderErrorCode, + PerpsCancelOrderRejection, + PerpsCancelOrderResult, + PerpsCancelOrderSuccess, + PerpsCancelRetryOptions, PerpsCandle, PerpsCredentials, PerpsDeposit, @@ -325,6 +330,11 @@ "Paginator", "PerpsBalance", "PerpsBook", + "PerpsCancelOrderErrorCode", + "PerpsCancelOrderRejection", + "PerpsCancelOrderResult", + "PerpsCancelOrderSuccess", + "PerpsCancelRetryOptions", "PerpsCandle", "PerpsCredentials", "PerpsDeposit", diff --git a/src/polymarket/_internal/perps_session.py b/src/polymarket/_internal/perps_session.py index 5592d4b5..923d5168 100644 --- a/src/polymarket/_internal/perps_session.py +++ b/src/polymarket/_internal/perps_session.py @@ -9,7 +9,9 @@ from dataclasses import dataclass from datetime import datetime from types import TracebackType -from typing import Any, Literal, Self, cast, overload +from typing import Any, Literal, Self, TypeVar, cast, overload + +from pydantic import TypeAdapter, ValidationError from polymarket._internal.actions.perps import account as _account from polymarket._internal.actions.perps.paging import to_epoch_ms @@ -40,12 +42,14 @@ ) from polymarket._internal.streams.perps.heartbeat import PerpsWebSocketHeartbeat from polymarket._internal.streams.reconnect import ReconnectScheduler +from polymarket._internal.ws.backoff import jittered_backoff from polymarket._internal.ws.connection import AsyncWebSocketConnection from polymarket.clients._transport import AsyncTransport from polymarket.errors import ( AutoCancelDailyLimitError, RequestRejectedError, TransportError, + UnexpectedResponseError, UserInputError, ) from polymarket.errors import ( @@ -75,6 +79,8 @@ from polymarket.models.perps.orders import ( PerpsAutoCancelResponse, PerpsCancelAllOrdersResponse, + PerpsCancelOrderErrorCode, + PerpsCancelOrderRejection, PerpsCancelOrderResult, PerpsFill, PerpsOrder, @@ -83,6 +89,7 @@ ) from polymarket.models.perps.requests import ( DecimalInput, + PerpsCancelRetryOptions, PerpsOrderRequest, PerpsPositionTpSlTrigger, PerpsTpSlTrigger, @@ -105,6 +112,11 @@ _ACK_TIMEOUT_S = 30.0 _MIN_AUTO_CANCEL_BUFFER_MS = 5_000 _QUEUE_SIZE = 1024 +_CANCEL_RETRY_BASE_DELAY_S = 0.1 +_CANCEL_RETRY_MAX_DELAY_S = 1.0 + +_CancelIdentifier = TypeVar("_CancelIdentifier", int, str) +_CANCEL_RESULT_ADAPTER: TypeAdapter[PerpsCancelOrderResult] = TypeAdapter(PerpsCancelOrderResult) _SESSION_CHANNELS = ( "balances", @@ -375,6 +387,7 @@ async def cancel_order( order_id: int, client_order_id: None = None, expires_at: datetime | int | None = None, + retry: PerpsCancelRetryOptions | Literal[False] | None = None, ) -> PerpsCancelOrderResult: ... @overload async def cancel_order( @@ -383,6 +396,7 @@ async def cancel_order( client_order_id: str, order_id: None = None, expires_at: datetime | int | None = None, + retry: PerpsCancelRetryOptions | Literal[False] | None = None, ) -> PerpsCancelOrderResult: ... async def cancel_order( self, @@ -390,20 +404,24 @@ async def cancel_order( order_id: int | None = None, client_order_id: str | None = None, expires_at: datetime | int | None = None, + retry: PerpsCancelRetryOptions | Literal[False] | None = None, ) -> PerpsCancelOrderResult: """Cancel one order by ``order_id`` or ``client_order_id``. - Provide exactly one identifier. The returned status reflects whether - the cancel happened. + Provide exactly one identifier. Only ``order_in_flight`` rejections are + retried. Pass ``retry=False`` to make one attempt. If the retry budget + is exhausted, the final ``order_in_flight`` result is returned. """ if (order_id is None) == (client_order_id is None): raise UserInputError("Provide exactly one of order_id or client_order_id") if order_id is not None: - results = await self.cancel_orders(order_ids=[order_id], expires_at=expires_at) + results = await self.cancel_orders( + order_ids=[order_id], expires_at=expires_at, retry=retry + ) else: assert client_order_id is not None results = await self.cancel_orders( - client_order_ids=[client_order_id], expires_at=expires_at + client_order_ids=[client_order_id], expires_at=expires_at, retry=retry ) return results[0] @@ -414,6 +432,7 @@ async def cancel_orders( order_ids: Sequence[int], client_order_ids: None = None, expires_at: datetime | int | None = None, + retry: PerpsCancelRetryOptions | Literal[False] | None = None, ) -> tuple[PerpsCancelOrderResult, ...]: ... @overload async def cancel_orders( @@ -422,6 +441,7 @@ async def cancel_orders( client_order_ids: Sequence[str], order_ids: None = None, expires_at: datetime | int | None = None, + retry: PerpsCancelRetryOptions | Literal[False] | None = None, ) -> tuple[PerpsCancelOrderResult, ...]: ... async def cancel_orders( self, @@ -429,26 +449,105 @@ async def cancel_orders( order_ids: Sequence[int] | None = None, client_order_ids: Sequence[str] | None = None, expires_at: datetime | int | None = None, + retry: PerpsCancelRetryOptions | Literal[False] | None = None, ) -> tuple[PerpsCancelOrderResult, ...]: """Cancel orders and return one result per requested order. Provide exactly one identifier list: ``order_ids`` or - ``client_order_ids``. + ``client_order_ids``. Only results rejected with ``order_in_flight`` + are retried; terminal results retain their original position. Pass + ``retry=False`` to make one attempt. """ if (order_ids is None) == (client_order_ids is None): raise UserInputError("Provide exactly one of order_ids or client_order_ids") + expires_at_ms = to_epoch_ms("expires_at", expires_at) if order_ids is not None: - op = cancel_orders_op(order_ids) - else: - assert client_order_ids is not None - op = cancel_orders_by_client_id_op(client_order_ids) - results = await self._send_signed_command( - op, - parse=_parse_cancel_results, - timeout_message="Perps cancel order response timed out.", - expires_at=expires_at, + identifiers = tuple(order_ids) + if not identifiers: + raise UserInputError("order_ids must be non-empty") + return await self._cancel_orders_with_retry( + identifiers, + build_op=cancel_orders_op, + expires_at_ms=expires_at_ms, + retry=retry, + ) + assert client_order_ids is not None + identifiers = tuple(client_order_ids) + if not identifiers: + raise UserInputError("client_order_ids must be non-empty") + return await self._cancel_orders_with_retry( + identifiers, + build_op=cancel_orders_by_client_id_op, + expires_at_ms=expires_at_ms, + retry=retry, ) - return tuple(results) + + async def _cancel_orders_with_retry( + self, + identifiers: Sequence[_CancelIdentifier], + *, + build_op: Callable[[Sequence[_CancelIdentifier]], list[Any]], + expires_at_ms: int | None, + retry: PerpsCancelRetryOptions | Literal[False] | None, + ) -> tuple[PerpsCancelOrderResult, ...]: + if retry is False: + max_attempts = 1 + retry_deadline = float("inf") + else: + if retry is None: + retry = PerpsCancelRetryOptions() + elif not isinstance(retry, PerpsCancelRetryOptions): # pyright: ignore[reportUnnecessaryIsInstance] + raise UserInputError("retry must be PerpsCancelRetryOptions, False, or None") + max_attempts = retry.max_attempts + retry_deadline = asyncio.get_running_loop().time() + retry.max_elapsed_s + + final_results: list[PerpsCancelOrderResult | None] = [None] * len(identifiers) + pending = list(enumerate(identifiers)) + attempts = 0 + + while pending: + if attempts > 0: + delay_s = jittered_backoff( + attempts - 1, + base_s=_CANCEL_RETRY_BASE_DELAY_S, + max_s=_CANCEL_RETRY_MAX_DELAY_S, + ) + remaining_s = _remaining_cancel_retry_s(retry_deadline, expires_at_ms=expires_at_ms) + if remaining_s <= 0 or delay_s >= remaining_s: + break + await asyncio.sleep(delay_s) + if _remaining_cancel_retry_s(retry_deadline, expires_at_ms=expires_at_ms) <= 0: + break + + attempt_results = await self._send_signed_command( + build_op([identifier for _, identifier in pending]), + parse=_parse_cancel_results, + timeout_message="Perps cancel order response timed out.", + expires_at=expires_at_ms, + ) + if len(attempt_results) != len(pending): + raise UnexpectedResponseError( + "Perps cancel response did not include one result per requested order." + ) + + attempts += 1 + retryable: list[tuple[int, _CancelIdentifier]] = [] + for (result_index, identifier), result in zip(pending, attempt_results, strict=True): + final_results[result_index] = result + if ( + attempts < max_attempts + and isinstance(result, PerpsCancelOrderRejection) + and result.error == PerpsCancelOrderErrorCode.ORDER_IN_FLIGHT + ): + retryable.append((result_index, identifier)) + pending = retryable + + resolved: list[PerpsCancelOrderResult] = [] + for result in final_results: + if result is None: + raise RuntimeError("expected a final Perps cancel order result") + resolved.append(result) + return tuple(resolved) async def cancel_all_orders( self, @@ -930,7 +1029,7 @@ def _handle_response(self, raw: object) -> bool: data = message.get("data") try: result = pending.parse(data) - except RequestRejectedError as error: + except (RequestRejectedError, UnexpectedResponseError) as error: self._reject_future(pending.future, error) return True except Exception: @@ -1077,8 +1176,22 @@ def _parse_post_order_acks(data: object) -> list[PerpsPostOrderAck]: def _parse_cancel_results(data: object) -> list[PerpsCancelOrderResult]: if not isinstance(data, list): - raise ValueError("expected a list of Perps cancel order results") - return [PerpsCancelOrderResult.parse_response(item) for item in cast("list[object]", data)] + raise UnexpectedResponseError( + "Perps cancel order results response did not match expected shape" + ) + try: + return [_CANCEL_RESULT_ADAPTER.validate_python(item) for item in cast("list[object]", data)] + except ValidationError as error: + raise UnexpectedResponseError( + "Perps cancel order result response did not match expected shape" + ) from error + + +def _remaining_cancel_retry_s(retry_deadline: float, *, expires_at_ms: int | None) -> float: + remaining_s = retry_deadline - asyncio.get_running_loop().time() + if expires_at_ms is not None: + remaining_s = min(remaining_s, (expires_at_ms - now_ms()) / 1_000) + return remaining_s def _error_ack(value: object) -> str | None: diff --git a/src/polymarket/models/__init__.py b/src/polymarket/models/__init__.py index 86e3b4c6..505c8348 100644 --- a/src/polymarket/models/__init__.py +++ b/src/polymarket/models/__init__.py @@ -128,7 +128,11 @@ PerpsBookDepth, PerpsBookLevel, PerpsBookUpdate, + PerpsCancelOrderErrorCode, + PerpsCancelOrderRejection, PerpsCancelOrderResult, + PerpsCancelOrderSuccess, + PerpsCancelRetryOptions, PerpsCandle, PerpsCandleBatch, PerpsClientOrderId, @@ -318,7 +322,11 @@ "PerpsBookDepth", "PerpsBookLevel", "PerpsBookUpdate", + "PerpsCancelOrderErrorCode", + "PerpsCancelOrderRejection", "PerpsCancelOrderResult", + "PerpsCancelOrderSuccess", + "PerpsCancelRetryOptions", "PerpsCandle", "PerpsCandleBatch", "PerpsClientOrderId", diff --git a/src/polymarket/models/perps/__init__.py b/src/polymarket/models/perps/__init__.py index 5c814ad4..5902fd27 100644 --- a/src/polymarket/models/perps/__init__.py +++ b/src/polymarket/models/perps/__init__.py @@ -80,7 +80,10 @@ PerpsPositionLiquidatedNotification, ) from polymarket.models.perps.orders import ( + PerpsCancelOrderErrorCode, + PerpsCancelOrderRejection, PerpsCancelOrderResult, + PerpsCancelOrderSuccess, PerpsFill, PerpsOrder, PerpsPostOrderAck, @@ -88,6 +91,7 @@ PerpsUpdateLeverageResult, ) from polymarket.models.perps.requests import ( + PerpsCancelRetryOptions, PerpsOrderRequest, PerpsPositionTpSlTrigger, PerpsTpSlTrigger, @@ -134,7 +138,11 @@ "PerpsBookEvent", "PerpsBookLevel", "PerpsBookUpdate", + "PerpsCancelOrderErrorCode", + "PerpsCancelOrderRejection", "PerpsCancelOrderResult", + "PerpsCancelOrderSuccess", + "PerpsCancelRetryOptions", "PerpsCandle", "PerpsCandleBatch", "PerpsCandleEvent", diff --git a/src/polymarket/models/perps/orders.py b/src/polymarket/models/perps/orders.py index b2f92b16..9300ea21 100644 --- a/src/polymarket/models/perps/orders.py +++ b/src/polymarket/models/perps/orders.py @@ -2,7 +2,8 @@ from datetime import datetime from decimal import Decimal -from typing import Any, Literal, cast +from enum import StrEnum +from typing import Annotated, Any, Literal, TypeAlias, cast from pydantic import AliasChoices, Field, field_validator, model_validator @@ -28,6 +29,16 @@ _DEFAULT_ACK_ERROR = "Perps command was rejected." +class PerpsCancelOrderErrorCode(StrEnum): + """Stable rejection identifiers for Perps order cancellations.""" + + ORDER_UNKNOWN = "order_unknown" + ORDER_NOT_IN_ORDERBOOK = "order_not_in_orderbook" + ORDER_IN_FLIGHT = "order_in_flight" + ORDER_NOT_PENDING_ENGINE = "order_not_pending_engine" + ORDER_NOT_FOUND = "order_not_found" + + def _side_from_buy(value: object) -> OrderSide: if value is True: return "BUY" @@ -175,18 +186,28 @@ def _check(self) -> "PerpsPostOrderAck": return self -class PerpsCancelOrderResult(BaseModel): - """Result of one Perps order cancellation.""" +class PerpsCancelOrderSuccess(BaseModel): + """A successfully cancelled Perps order.""" - status: Literal["ok", "err"] + status: Literal["ok"] order_id: PerpsOrderId | None = Field(default=None, validation_alias="oid") client_order_id: str | None = Field(default=None, validation_alias="coid") - error: str | None = None - @model_validator(mode="before") - @classmethod - def _normalize(cls, data: object) -> object: - return _default_ack_error(data) + +class PerpsCancelOrderRejection(BaseModel): + """A rejected Perps order cancellation.""" + + status: Literal["err"] + error: PerpsCancelOrderErrorCode + order_id: PerpsOrderId | None = Field(default=None, validation_alias="oid") + client_order_id: str | None = Field(default=None, validation_alias="coid") + + +PerpsCancelOrderResult: TypeAlias = Annotated[ + PerpsCancelOrderSuccess | PerpsCancelOrderRejection, + Field(discriminator="status"), +] +"""Result of one Perps order cancellation.""" class PerpsCancelAllOrdersResponse(BaseModel): @@ -223,7 +244,10 @@ class PerpsUpdateLeverageResult(BaseModel): __all__ = [ "PerpsAutoCancelResponse", "PerpsCancelAllOrdersResponse", + "PerpsCancelOrderErrorCode", + "PerpsCancelOrderRejection", "PerpsCancelOrderResult", + "PerpsCancelOrderSuccess", "PerpsFill", "PerpsOrder", "PerpsPostOrderAck", diff --git a/src/polymarket/models/perps/requests.py b/src/polymarket/models/perps/requests.py index 1e6de1a7..2ffdaff8 100644 --- a/src/polymarket/models/perps/requests.py +++ b/src/polymarket/models/perps/requests.py @@ -1,5 +1,6 @@ """Perps order request inputs.""" +import math import re from dataclasses import dataclass from decimal import Decimal, InvalidOperation @@ -15,6 +16,31 @@ """Decimal-valued input accepted for Perps prices and quantities.""" +@dataclass(frozen=True, slots=True, kw_only=True) +class PerpsCancelRetryOptions: + """Bounds automatic retries for transient Perps cancellation rejections.""" + + max_attempts: int = 4 + """Maximum attempts per order, including the initial request.""" + max_elapsed_s: float = 2.0 + """Maximum elapsed time before starting a retry, in seconds.""" + + def __post_init__(self) -> None: + if isinstance(self.max_attempts, bool) or not isinstance( # pyright: ignore[reportUnnecessaryIsInstance] + self.max_attempts, int + ): + raise UserInputError("max_attempts must be an int") + if self.max_attempts < 1: + raise UserInputError("max_attempts must be at least 1") + if isinstance(self.max_elapsed_s, bool) or not isinstance( # pyright: ignore[reportUnnecessaryIsInstance] + self.max_elapsed_s, (int, float) + ): + raise UserInputError("max_elapsed_s must be a number") + if not math.isfinite(self.max_elapsed_s) or self.max_elapsed_s <= 0: + raise UserInputError("max_elapsed_s must be finite and positive") + object.__setattr__(self, "max_elapsed_s", float(self.max_elapsed_s)) + + def to_decimal_string(name: str, value: DecimalInput) -> str: """Normalize a decimal input into its canonical wire string.""" if isinstance(value, bool): @@ -168,6 +194,7 @@ def __post_init__(self) -> None: __all__ = [ "DecimalInput", + "PerpsCancelRetryOptions", "PerpsOrderRequest", "PerpsPositionTpSlTrigger", "PerpsTpSlTrigger", diff --git a/src/polymarket/perps.py b/src/polymarket/perps.py index 2f97b6f1..89381266 100644 --- a/src/polymarket/perps.py +++ b/src/polymarket/perps.py @@ -12,6 +12,13 @@ PerpsNotificationsPage, PerpsNotificationsPaginator, ) +from polymarket.models.perps.orders import ( + PerpsCancelOrderErrorCode, + PerpsCancelOrderRejection, + PerpsCancelOrderResult, + PerpsCancelOrderSuccess, +) +from polymarket.models.perps.requests import PerpsCancelRetryOptions from polymarket.models.perps.results import ( PerpsOrderPlacement, PerpsPlacedTpSlOrder, @@ -22,6 +29,11 @@ "PerpsNotificationEntry", "PerpsNotificationsPage", "PerpsNotificationsPaginator", + "PerpsCancelOrderErrorCode", + "PerpsCancelOrderRejection", + "PerpsCancelOrderResult", + "PerpsCancelOrderSuccess", + "PerpsCancelRetryOptions", "PerpsOrderPlacement", "PerpsPlacedTpSlOrder", "PerpsPlacedTpSlOrders", diff --git a/tests/unit/test_perps_cancel.py b/tests/unit/test_perps_cancel.py new file mode 100644 index 00000000..82e10e7b --- /dev/null +++ b/tests/unit/test_perps_cancel.py @@ -0,0 +1,296 @@ +# pyright: reportPrivateUsage=false +"""Typed Perps cancellation and bounded retry behavior.""" + +import asyncio +from collections.abc import Callable +from datetime import UTC, datetime +from typing import Any + +import pytest + +from polymarket import ( + PerpsCancelOrderErrorCode, + PerpsCancelOrderRejection, + PerpsCancelOrderSuccess, + PerpsCancelRetryOptions, +) +from polymarket._internal import perps_session +from polymarket._internal.perps_session import PerpsSession +from polymarket.errors import UnexpectedResponseError, UserInputError +from polymarket.models.perps.credentials import PerpsCredentials + +_PRIVATE_KEY = "0x0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" +_PROXY = "0x14791697260E4c9A71f18484C9f997B308e59325" +_CLIENT_ORDER_ID_A = "aabbccddeeff00112233445566778899" +_CLIENT_ORDER_ID_B = "00112233445566778899aabbccddeeff" + +_CREDENTIALS = PerpsCredentials( + proxy=_PROXY, + private_key=_PRIVATE_KEY, + secret="session-secret", + expires_at=datetime(2030, 1, 1, tzinfo=UTC), +) + + +def _session() -> PerpsSession: + return PerpsSession( + chain_id=137, + credentials=_CREDENTIALS, + rest_url="https://perps.test", + ws_url="ws://127.0.0.1:9", + ) + + +def _stub_cancel_responses( + monkeypatch: pytest.MonkeyPatch, + session: PerpsSession, + responses: list[list[dict[str, object]]], +) -> list[list[Any]]: + commands: list[list[Any]] = [] + response_index = 0 + + async def send_signed_command( + op: list[Any], + *, + parse: Callable[[object], Any], + timeout_message: str, + expires_at: datetime | int | None = None, + ) -> Any: + nonlocal response_index + del timeout_message, expires_at + commands.append(op) + if response_index >= len(responses): + raise AssertionError("unexpected Perps cancellation attempt") + response = responses[response_index] + response_index += 1 + return parse(response) + + monkeypatch.setattr(session, "_send_signed_command", send_signed_command) + return commands + + +def _no_delay(attempt: int, *, base_s: float, max_s: float) -> float: + del attempt, base_s, max_s + return 0.0 + + +def test_cancel_rejections_require_a_known_error_code() -> None: + with pytest.raises(UnexpectedResponseError): + perps_session._parse_cancel_results([{"status": "err", "error": "new"}]) + + +def test_cancel_results_require_a_list_response() -> None: + with pytest.raises(UnexpectedResponseError): + perps_session._parse_cancel_results({"status": "ok"}) + + +def test_retries_only_in_flight_batch_results_and_preserves_order( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(perps_session, "jittered_backoff", _no_delay) + + async def run() -> None: + session = _session() + commands = _stub_cancel_responses( + monkeypatch, + session, + [ + [ + {"status": "err", "oid": 1, "error": "order_in_flight"}, + {"status": "err", "oid": 2, "error": "order_not_found"}, + {"status": "ok", "oid": 3}, + ], + [{"status": "ok", "oid": 1}], + ], + ) + try: + results = await session.cancel_orders( + order_ids=[1, 2, 3], + retry=PerpsCancelRetryOptions(max_attempts=2, max_elapsed_s=10), + ) + finally: + await session.close() + + assert commands == [ + ["cancelOrders", [1, 2, 3]], + ["cancelOrders", [1]], + ] + assert isinstance(results[0], PerpsCancelOrderSuccess) + assert isinstance(results[1], PerpsCancelOrderRejection) + assert results[1].error is PerpsCancelOrderErrorCode.ORDER_NOT_FOUND + assert isinstance(results[2], PerpsCancelOrderSuccess) + + asyncio.run(run()) + + +def test_retry_false_makes_one_attempt(monkeypatch: pytest.MonkeyPatch) -> None: + async def run() -> None: + session = _session() + commands = _stub_cancel_responses( + monkeypatch, + session, + [[{"status": "err", "oid": 1, "error": "order_in_flight"}]], + ) + try: + [result] = await session.cancel_orders(order_ids=[1], retry=False) + finally: + await session.close() + + assert isinstance(result, PerpsCancelOrderRejection) + assert result.error is PerpsCancelOrderErrorCode.ORDER_IN_FLIGHT + assert len(commands) == 1 + + asyncio.run(run()) + + +def test_attempt_limit_returns_the_latest_in_flight_result( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(perps_session, "jittered_backoff", _no_delay) + + async def run() -> None: + session = _session() + commands = _stub_cancel_responses( + monkeypatch, + session, + [ + [{"status": "err", "error": "order_in_flight"}], + [{"status": "err", "error": "order_in_flight"}], + [{"status": "err", "oid": 1, "error": "order_in_flight"}], + ], + ) + try: + [result] = await session.cancel_orders( + order_ids=[1], + retry=PerpsCancelRetryOptions(max_attempts=3, max_elapsed_s=10), + ) + finally: + await session.close() + + assert isinstance(result, PerpsCancelOrderRejection) + assert result.order_id == 1 + assert len(commands) == 3 + + asyncio.run(run()) + + +def test_elapsed_budget_prevents_another_attempt(monkeypatch: pytest.MonkeyPatch) -> None: + def delay(attempt: int, *, base_s: float, max_s: float) -> float: + del attempt, base_s, max_s + return 0.1 + + monkeypatch.setattr(perps_session, "jittered_backoff", delay) + + async def run() -> None: + session = _session() + commands = _stub_cancel_responses( + monkeypatch, + session, + [[{"status": "err", "oid": 1, "error": "order_in_flight"}]], + ) + try: + [result] = await session.cancel_orders( + order_ids=[1], + retry=PerpsCancelRetryOptions(max_attempts=4, max_elapsed_s=0.05), + ) + finally: + await session.close() + + assert isinstance(result, PerpsCancelOrderRejection) + assert len(commands) == 1 + + asyncio.run(run()) + + +def test_expiration_deadline_prevents_another_attempt(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(perps_session, "jittered_backoff", _no_delay) + + async def run() -> None: + session = _session() + commands = _stub_cancel_responses( + monkeypatch, + session, + [[{"status": "err", "oid": 1, "error": "order_in_flight"}]], + ) + try: + [result] = await session.cancel_orders( + order_ids=[1], + expires_at=perps_session.now_ms(), + ) + finally: + await session.close() + + assert isinstance(result, PerpsCancelOrderRejection) + assert len(commands) == 1 + + asyncio.run(run()) + + +def test_client_order_id_retries_only_the_transient_subset( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(perps_session, "jittered_backoff", _no_delay) + + async def run() -> None: + session = _session() + commands = _stub_cancel_responses( + monkeypatch, + session, + [ + [ + {"status": "err", "coid": _CLIENT_ORDER_ID_A, "error": "order_not_found"}, + {"status": "err", "coid": _CLIENT_ORDER_ID_B, "error": "order_in_flight"}, + ], + [{"status": "ok", "coid": _CLIENT_ORDER_ID_B}], + ], + ) + try: + results = await session.cancel_orders( + client_order_ids=[_CLIENT_ORDER_ID_A, _CLIENT_ORDER_ID_B], + retry=PerpsCancelRetryOptions(max_attempts=2, max_elapsed_s=10), + ) + finally: + await session.close() + + assert commands == [ + ["cancelOrdersCOID", [_CLIENT_ORDER_ID_A, _CLIENT_ORDER_ID_B]], + ["cancelOrdersCOID", [_CLIENT_ORDER_ID_B]], + ] + assert isinstance(results[0], PerpsCancelOrderRejection) + assert isinstance(results[1], PerpsCancelOrderSuccess) + + asyncio.run(run()) + + +def test_cancel_response_cardinality_must_match_request( + monkeypatch: pytest.MonkeyPatch, +) -> None: + async def run() -> None: + session = _session() + _stub_cancel_responses( + monkeypatch, + session, + [[{"status": "ok", "oid": 1}]], + ) + try: + with pytest.raises(UnexpectedResponseError, match="one result per requested order"): + await session.cancel_orders(order_ids=[1, 2], retry=False) + finally: + await session.close() + + asyncio.run(run()) + + +@pytest.mark.parametrize( + "options", + [ + {"max_attempts": True}, + {"max_attempts": 0}, + {"max_elapsed_s": True}, + {"max_elapsed_s": 0}, + {"max_elapsed_s": float("inf")}, + ], +) +def test_retry_options_reject_invalid_bounds(options: dict[str, object]) -> None: + with pytest.raises(UserInputError): + PerpsCancelRetryOptions(**options) # type: ignore[arg-type] diff --git a/tests/unit/test_perps_session.py b/tests/unit/test_perps_session.py index 6ad8a0d0..301e3264 100644 --- a/tests/unit/test_perps_session.py +++ b/tests/unit/test_perps_session.py @@ -30,6 +30,7 @@ PerpsOrderEvent, PerpsResyncEvent, ) +from polymarket.models.perps.orders import PerpsCancelOrderErrorCode Handler = Callable[[ServerConnection], Awaitable[None]] @@ -607,7 +608,7 @@ async def handler(ws: ServerConnection) -> None: json.dumps( { "id": message["id"], - "data": [{"status": "err", "oid": 55, "error": "order not found"}], + "data": [{"status": "err", "oid": 55, "error": "order_not_found"}], } ) ) @@ -616,7 +617,7 @@ async def run() -> None: async with ws_server(handler) as url, _open_session(url) as session: result = await session.cancel_order(order_id=55) assert result.status == "err" - assert result.error == "order not found" + assert result.error is PerpsCancelOrderErrorCode.ORDER_NOT_FOUND asyncio.run(asyncio.wait_for(run(), timeout=10.0)) diff --git a/tests/unit/test_perps_typing.py b/tests/unit/test_perps_typing.py index eb8b3d03..19aa593c 100644 --- a/tests/unit/test_perps_typing.py +++ b/tests/unit/test_perps_typing.py @@ -7,7 +7,11 @@ from typing import TYPE_CHECKING, Any, assert_type from polymarket.models.perps import ( + PerpsCancelOrderErrorCode, + PerpsCancelOrderRejection, PerpsCancelOrderResult, + PerpsCancelOrderSuccess, + PerpsCancelRetryOptions, PerpsOrderRequest, PerpsPostOrderAck, ) @@ -48,7 +52,10 @@ async def _check_session_typing(session: PerpsSession) -> None: PerpsOrderPlacement, ) assert_type( - await session.cancel_order(order_id=1), + await session.cancel_order( + order_id=1, + retry=PerpsCancelRetryOptions(max_attempts=2, max_elapsed_s=1.0), + ), PerpsCancelOrderResult, ) assert_type( @@ -56,9 +63,15 @@ async def _check_session_typing(session: PerpsSession) -> None: PerpsCancelOrderResult, ) assert_type( - await session.cancel_orders(order_ids=[1, 2]), + await session.cancel_orders(order_ids=[1, 2], retry=False), tuple[PerpsCancelOrderResult, ...], ) + result = await session.cancel_order(order_id=1) + if result.status == "err": + assert_type(result, PerpsCancelOrderRejection) + assert_type(result.error, PerpsCancelOrderErrorCode) + else: + assert_type(result, PerpsCancelOrderSuccess) assert_type( await session.post_orders( [