Coverage for adrs/oms/rate_limit/error_policy.py: 98%

66 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-08-29 05:07 +0800

1import re 

2from abc import ABC, abstractmethod 

3from enum import Enum, auto 

4 

5from cybotrade.binance import BinanceError 

6from cybotrade.bybit import BybitError 

7from cybotrade.hyperliquid import HyperliquidError 

8 

9# Binance reports rate limiting through the JSON body code, never the HTTP 

10# status: -1003 arrives with both 429 (request budget exhausted) and 418 (IP 

11# banned), and -1015 with an order-rate breach. Matching 418/429 against 

12# BinanceError.code -- which only ever holds the body code -- can therefore 

13# never fire, which is how an OMS came to poll straight through an hour-long IP 

14# ban, renewing it with every request. 

15BINANCE_RATE_LIMIT_CODES = frozenset({-1003, -1015}) 

16# Scoped to the account's order budget, so these only need to stop order 

17# placement; reads can carry on. 

18BINANCE_ORDER_RATE_LIMIT_CODES = frozenset({-1015}) 

19# Checked as well as the body code, because a 429/418 raised by Binance's edge 

20# rather than its matching engine can carry a body with no code field at all. 

21BINANCE_RATE_LIMIT_HTTP_STATUSES = frozenset({418, 429}) 

22 

23# Binance embeds the ban deadline in the -1003 message, e.g. "Way too many 

24# requests; IP(1.2.3.4) banned until 1786361397856." That epoch-ms deadline is 

25# the only authoritative recovery time on the response, since a 418 frequently 

26# carries no Retry-After header. 

27_BINANCE_BANNED_UNTIL_RE = re.compile(r"banned until (\d{13,})") 

28 

29 

30def is_binance_rate_limit_error(exc: BinanceError) -> bool: 

31 """Whether this Binance error means we are being rate limited or banned.""" 

32 if exc.code in BINANCE_RATE_LIMIT_CODES: 

33 return True 

34 # cybotrade only began carrying the HTTP status on BinanceError in 2.0.19; 

35 # on older versions the attribute is absent and the body code is all there 

36 # is to go on. 

37 return getattr(exc, "http_status", None) in BINANCE_RATE_LIMIT_HTTP_STATUSES 

38 

39 

40def binance_banned_until_ms(message: str) -> int | None: 

41 """Epoch-ms ban deadline from a -1003 message, or None if it carries none.""" 

42 match = _BINANCE_BANNED_UNTIL_RE.search(message) 

43 if match is None: 

44 return None 

45 return int(match.group(1)) 

46 

47 

48class ErrorAction(Enum): 

49 TERMINAL_SUCCESS = auto() # order already gone; treat as done, no retry 

50 RETRY = auto() # transient/unknown; backlog + backoff (default) 

51 RATE_LIMITED = auto() # rate code; cooldown is armed in the limiter's guard() 

52 FATAL = auto() # unrecoverable for these params; drop + log, no retry 

53 

54 

55# Unlisted codes fall through to RETRY, matching legacy behaviour. FATAL entries 

56# stop retries, so only add a code here once its terminal nature is confirmed. 

57BYBIT_ERROR_ACTIONS: dict[int, ErrorAction] = { 

58 110001: ErrorAction.TERMINAL_SUCCESS, # order not exists or too late to cancel 

59 10006: ErrorAction.RATE_LIMITED, # too many visits 

60} 

61 

62# Keyed on Binance's body codes. The 418/429 that used to sit here were HTTP 

63# statuses and so never matched; a rate-limit error fell through to RETRY, and 

64# the backlog kept re-sending orders every 2s for the length of the ban. 

65BINANCE_ERROR_ACTIONS: dict[int, ErrorAction] = { 

66 -2011: ErrorAction.TERMINAL_SUCCESS, # unknown order (already gone) 

67 **{code: ErrorAction.RATE_LIMITED for code in BINANCE_RATE_LIMIT_CODES}, 

68} 

69 

70 

71class ExchangeErrorPolicy(ABC): 

72 """Maps an exchange exception to the behaviour the OMS should take.""" 

73 

74 default_action: ErrorAction = ErrorAction.RETRY 

75 

76 @abstractmethod 

77 def classify(self, exc: Exception) -> ErrorAction: ... 

78 

79 

80class BybitErrorPolicy(ExchangeErrorPolicy): 

81 def classify(self, exc: Exception) -> ErrorAction: 

82 if isinstance(exc, BybitError): 

83 if exc.http_status == 403: 

84 return ErrorAction.RATE_LIMITED 

85 if exc.retCode is not None: 

86 return BYBIT_ERROR_ACTIONS.get(exc.retCode, self.default_action) 

87 return self.default_action 

88 

89 

90class BinanceErrorPolicy(ExchangeErrorPolicy): 

91 def classify(self, exc: Exception) -> ErrorAction: 

92 if not isinstance(exc, BinanceError): 

93 return self.default_action 

94 # Status first: a 429/418 whose body has no code at all is still a rate 

95 # limit, and must not be retried as though it were transient. 

96 if is_binance_rate_limit_error(exc): 

97 return ErrorAction.RATE_LIMITED 

98 if exc.code is not None: 

99 return BINANCE_ERROR_ACTIONS.get(exc.code, self.default_action) 

100 return self.default_action 

101 

102 

103class DefaultErrorPolicy(ExchangeErrorPolicy): 

104 """Everything retries — legacy behaviour for exchanges without a policy.""" 

105 

106 def classify(self, exc: Exception) -> ErrorAction: 

107 return self.default_action 

108 

109 

110# Hyperliquid reports failure in the body of an HTTP 200 and offers no stable 

111# numeric code, so classification is by substring. Ordered: first match wins. 

112# A whitelist with an inert default -- anything unmatched keeps the legacy 

113# RETRY behaviour rather than being guessed at. 

114# 

115# Only the first entry is confirmed against the live exchange. The rest are the 

116# categories the OMS must not retry blindly; drop any that cannot be confirmed 

117# rather than keep a match that silently never fires. 

118HYPERLIQUID_ERROR_ACTIONS: tuple[tuple[str, ErrorAction], ...] = ( 

119 # Verified live: returned when cancelling an order id that is already gone. 

120 # The caller wanted it gone, so this is success, not failure -- the same 

121 # reading as Bybit's 110001. 

122 ("was never placed, already canceled, or filled", ErrorAction.TERMINAL_SUCCESS), 

123 ("rate limit", ErrorAction.RATE_LIMITED), 

124 ("too many requests", ErrorAction.RATE_LIMITED), 

125 ("insufficient margin", ErrorAction.FATAL), 

126 ("too far", ErrorAction.FATAL), 

127 ("reduce only", ErrorAction.FATAL), 

128) 

129 

130# Checked as well as the message, and before it. Hyperliquid reports *action* 

131# failures in the body of an HTTP 200, which is why the needles above exist at 

132# all -- but a 429 comes from the edge, not the matching engine, and carries no 

133# Hyperliquid body to match against. Its text is whatever the proxy wrote, so 

134# the status is the only signal. Missing it is how a process polls straight 

135# through a throttle and renews it. 

136HYPERLIQUID_RATE_LIMIT_HTTP_STATUSES = frozenset({429}) 

137 

138 

139def _hyperliquid_action(exc: Exception) -> ErrorAction | None: 

140 """The mapped action for a HyperliquidError, or None if nothing matched.""" 

141 if not isinstance(exc, HyperliquidError): 

142 return None 

143 # Status first: a 429 is a rate limit whatever its body says, and must not 

144 # be read as a FATAL or fall through to a retry loop on message text alone. 

145 if getattr(exc, "status", None) in HYPERLIQUID_RATE_LIMIT_HTTP_STATUSES: 

146 return ErrorAction.RATE_LIMITED 

147 message = (exc.message or "").lower() 

148 for needle, action in HYPERLIQUID_ERROR_ACTIONS: 

149 if needle in message: 

150 return action 

151 return None 

152 

153 

154def is_hyperliquid_rate_limit_error(exc: Exception) -> bool: 

155 """ 

156 Whether this error is Hyperliquid throttling us. 

157 

158 True for an HTTP 429 regardless of message text, and for the throttle 

159 message needles. Used by HyperliquidRateLimiter to arm its cooldown, so it 

160 deliberately requires a HyperliquidError: an unrelated exception whose text 

161 happens to contain "rate limit" must not stall every call for a minute. 

162 """ 

163 return _hyperliquid_action(exc) is ErrorAction.RATE_LIMITED 

164 

165 

166class HyperliquidErrorPolicy(ExchangeErrorPolicy): 

167 def classify(self, exc: Exception) -> ErrorAction: 

168 action = _hyperliquid_action(exc) 

169 return self.default_action if action is None else action