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:
Codex
2026-07-13 15:43:55 +02:00
parent d9b7e05531
commit bdc54fbeaa
5 changed files with 529 additions and 19 deletions

View File

@@ -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