dita_v2(exec): INDETERMINATE submit is not REJECTED — unknown is never flat
A BingX read-timeout/reset/5xx after send means the answer was lost, not that the order failed. Classify every submit failure by what it PROVES: NOT_ATTEMPTED / REFUSED -> rollback sound; INDETERMINATE -> point-lookup our own clientOrderId (read-only, bounded, never a reconcile); unresolved stays UNKNOWN — no synthetic REJECT, no slot rollback, E-feed FILL settles truth. - prod/bingx/http.py: BingxHttpError.effect + order_may_exist, 9 raise sites tagged - adapters/bingx_direct.py: _lookup_own_order_by_client_id (never POSTs) - dita_v2/venue.py: VenueIndeterminateError(VenuePostAckError) — existing fences catch it - dita_v2/bingx_venue.py: both submit paths escalate INDETERMINATE receipts - 14 tests incl. kernel no-rollback invariant + genuine-REFUSED contrast Suite: 3416 passed, 19 skipped, 3 xfailed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -28,7 +28,53 @@ from .urls import get_rest_base_urls
|
||||
|
||||
|
||||
class BingxHttpError(RuntimeError):
|
||||
pass
|
||||
"""A BingX HTTP failure, carrying WHETHER THE ORDER MAY EXIST.
|
||||
|
||||
An operation with a side effect has three outcomes, not two, and rollback is
|
||||
sound for only two of them:
|
||||
|
||||
NOT_ATTEMPTED — the request never left us (no creds, connect refused, DNS).
|
||||
Provably no order. Safe to roll back.
|
||||
REFUSED — the venue answered and rejected it (business error code,
|
||||
4xx). Provably no order. Safe to roll back.
|
||||
INDETERMINATE — the request was sent and we did not get a usable answer
|
||||
(read timeout, connection reset, 5xx, unparseable body).
|
||||
THE ORDER MAY BE LIVE. Rolling back here asserts "no order
|
||||
exists" on exactly the evidence that cannot support it, and
|
||||
orphans a real position. Reconcile by clientOrderId instead.
|
||||
|
||||
Default is INDETERMINATE on purpose: when we do not know, we must not claim
|
||||
absence. Unknown is not flat.
|
||||
"""
|
||||
|
||||
NOT_ATTEMPTED = "NOT_ATTEMPTED"
|
||||
REFUSED = "REFUSED"
|
||||
INDETERMINATE = "INDETERMINATE"
|
||||
|
||||
def __init__(self, message: str, *, effect: str = "INDETERMINATE") -> None:
|
||||
super().__init__(message)
|
||||
self.effect = effect
|
||||
|
||||
@property
|
||||
def order_may_exist(self) -> bool:
|
||||
return self.effect == self.INDETERMINATE
|
||||
|
||||
|
||||
def _effect_of_httpx_error(exc: Exception) -> str:
|
||||
"""Classify a transport failure by whether the request could have been acted on."""
|
||||
# Never left the box -> provably no order.
|
||||
if isinstance(exc, (httpx.ConnectError, httpx.ConnectTimeout)):
|
||||
return BingxHttpError.NOT_ATTEMPTED
|
||||
# Sent, but the answer was lost (read/write/pool timeout, reset, protocol
|
||||
# error). The venue may well have matched it. UNKNOWN.
|
||||
return BingxHttpError.INDETERMINATE
|
||||
|
||||
|
||||
def _effect_of_status(status_code: int) -> str:
|
||||
"""4xx = the venue judged and refused. 5xx = it may have acted before failing."""
|
||||
if 400 <= status_code < 500:
|
||||
return BingxHttpError.REFUSED
|
||||
return BingxHttpError.INDETERMINATE
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -413,7 +459,8 @@ class BingxHttpClient:
|
||||
payload = dict(params)
|
||||
if signed:
|
||||
if not self._api_key or not self._secret_key:
|
||||
raise BingxHttpError("BingX API credentials are required for signed requests")
|
||||
raise BingxHttpError("BingX API credentials are required for signed requests",
|
||||
effect=BingxHttpError.NOT_ATTEMPTED)
|
||||
payload = build_signed_params(
|
||||
payload,
|
||||
self._secret_key,
|
||||
@@ -453,13 +500,15 @@ class BingxHttpClient:
|
||||
rate_limited=response.status_code == 429,
|
||||
retry_after_ms=self._rate_limits.snapshot().rest_reset_ms,
|
||||
)
|
||||
last_error = BingxHttpError(f"HTTP {response.status_code}: {text or response.reason_phrase}")
|
||||
last_error = BingxHttpError(f"HTTP {response.status_code}: {text or response.reason_phrase}",
|
||||
effect=_effect_of_status(response.status_code))
|
||||
if base_index < len(self._base_urls) - 1:
|
||||
continue
|
||||
if attempt < max_retries:
|
||||
await asyncio.sleep(delay)
|
||||
break
|
||||
raise BingxHttpError(f"HTTP {response.status_code}: {text or response.reason_phrase}")
|
||||
raise BingxHttpError(f"HTTP {response.status_code}: {text or response.reason_phrase}",
|
||||
effect=_effect_of_status(response.status_code))
|
||||
self._circuit_breaker.record_success()
|
||||
if hostname is not None:
|
||||
await self._maybe_refresh_dns_cache(hostname)
|
||||
@@ -487,7 +536,8 @@ class BingxHttpClient:
|
||||
except httpx.HTTPError as exc:
|
||||
self._logger.warning("HTTP error [att=%d url=%d %s]: %s — %s",
|
||||
attempt, base_index, method, type(exc).__name__, exc)
|
||||
last_error = BingxHttpError(f"{method} {path} failed: {exc}")
|
||||
last_error = BingxHttpError(f"{method} {path} failed: {exc}",
|
||||
effect=_effect_of_httpx_error(exc))
|
||||
if self._is_dns_resolution_error(exc):
|
||||
if hostname is not None:
|
||||
cached_ips = self._dns_cache.resolve(hostname)
|
||||
@@ -528,12 +578,14 @@ class BingxHttpClient:
|
||||
return self._unwrap_response(data)
|
||||
return data
|
||||
except Exception as fallback_exc:
|
||||
last_error = BingxHttpError(f"{method} {path} failed: {fallback_exc}")
|
||||
last_error = BingxHttpError(f"{method} {path} failed: {fallback_exc}",
|
||||
effect=_effect_of_httpx_error(fallback_exc))
|
||||
if base_index < len(self._base_urls) - 1:
|
||||
continue
|
||||
if last_error is not None:
|
||||
raise last_error
|
||||
raise BingxHttpError(f"{method} {path} failed: {exc}")
|
||||
raise BingxHttpError(f"{method} {path} failed: {exc}",
|
||||
effect=_effect_of_httpx_error(exc))
|
||||
delay = self._circuit_breaker.record_failure()
|
||||
if base_index < len(self._base_urls) - 1:
|
||||
continue
|
||||
@@ -584,12 +636,14 @@ class BingxHttpClient:
|
||||
return self._unwrap_response(data)
|
||||
return data
|
||||
except Exception as fallback_exc:
|
||||
last_error = BingxHttpError(f"{method} {path} failed: {fallback_exc}")
|
||||
last_error = BingxHttpError(f"{method} {path} failed: {fallback_exc}",
|
||||
effect=_effect_of_httpx_error(fallback_exc))
|
||||
if base_index < len(self._base_urls) - 1:
|
||||
continue
|
||||
if last_error is not None:
|
||||
raise last_error
|
||||
raise BingxHttpError(f"{method} {path} failed: {exc}")
|
||||
raise BingxHttpError(f"{method} {path} failed: {exc}",
|
||||
effect=_effect_of_httpx_error(exc))
|
||||
delay = self._circuit_breaker.record_failure()
|
||||
if base_index < len(self._base_urls) - 1:
|
||||
continue
|
||||
@@ -625,7 +679,8 @@ class BingxHttpClient:
|
||||
def _unwrap_response(payload: dict[str, Any]) -> Any:
|
||||
code = int(payload.get("code", -1))
|
||||
if code != 0:
|
||||
raise BingxHttpError(payload.get("msg", f"BingX error code {code}"))
|
||||
raise BingxHttpError(payload.get("msg", f"BingX error code {code}"),
|
||||
effect=BingxHttpError.REFUSED)
|
||||
return payload.get("data")
|
||||
|
||||
@staticmethod
|
||||
|
||||
Reference in New Issue
Block a user