Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
48 changes: 48 additions & 0 deletions .github/workflows/diagnose-account-facts-receiver.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
name: Diagnose Binance Account Facts Receiver

"on":
workflow_dispatch:

permissions:
contents: read

concurrency:
group: binance-account-facts-readonly
cancel-in-progress: false

jobs:
readiness:
if: github.event_name == 'workflow_dispatch' && github.repository == 'QuantStrategyLab/BinancePlatform' && github.ref == 'refs/heads/main'
runs-on: self-hosted
timeout-minutes: 2
environment: binance-runtime
steps:
- name: Checkout the exact dispatch revision
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
with:
ref: ${{ github.sha }}
persist-credentials: false

- name: Require the exact current main revision
shell: bash
run: |
set -euo pipefail
if [[ "$GITHUB_EVENT_NAME" != "workflow_dispatch" || \
"$GITHUB_REPOSITORY" != "QuantStrategyLab/BinancePlatform" || \
"$GITHUB_REF" != "refs/heads/main" || \
! "$GITHUB_SHA" =~ ^[0-9a-f]{40}$ || \
"$(git rev-parse HEAD)" != "$GITHUB_SHA" ]]; then
echo "receiver_diagnosis_source_unqualified" >&2
exit 1
fi
current_main_sha="$(git ls-remote https://github.com/QuantStrategyLab/BinancePlatform.git refs/heads/main 2>/dev/null | awk 'NF == 2 && $2 == "refs/heads/main" { print $1 }')"
if [[ ! "$current_main_sha" =~ ^[0-9a-f]{40}$ || "$current_main_sha" != "$GITHUB_SHA" ]]; then
echo "receiver_diagnosis_main_sha_mismatch" >&2
exit 1
fi

- name: Read receiver readiness once
shell: bash
env:
BINANCE_ACCOUNT_FACTS_SYNC_TOKEN: ${{ secrets.BINANCE_ACCOUNT_FACTS_SYNC_TOKEN }}
run: python3 scripts/diagnose_binance_account_facts_receiver.py
170 changes: 170 additions & 0 deletions scripts/diagnose_binance_account_facts_receiver.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
#!/usr/bin/env python3
"""Make one authenticated, read-only readiness request to the QRS receiver."""
from __future__ import annotations

import json
import os
from dataclasses import dataclass
from urllib.error import HTTPError
from urllib.request import HTTPRedirectHandler, Request, build_opener

QRS_ENDPOINT = "https://qsl-strategy-switch-console.pigbibi.workers.dev/api/internal/binance-account-facts"

_READINESS_FIELDS = {"ok", "ready", "binding_valid", "account_options_readable", "unique_match"}
_READINESS_ERROR_FIELDS = _READINESS_FIELDS | {"error"}
_TOKEN_ERROR_CODES = {"binance_account_facts_token_invalid"}
_BINDING_ERROR_CODES = {"binance_account_facts_binding_unmatched"}
_CONFIG_ERROR_CODES = {
"binance_account_facts_token_unavailable",
"binance_account_facts_binding_missing",
"binance_account_facts_binding_invalid",
}
_READINESS_ERROR_CODES = _TOKEN_ERROR_CODES | _BINDING_ERROR_CODES | _CONFIG_ERROR_CODES | {
"binance_account_facts_account_options_unavailable",
"binance_account_facts_unavailable",
}
_MAX_BODY_BYTES = 4096


class _NoRedirect(HTTPRedirectHandler):
def redirect_request(self, request, response, code, message, headers, new_url):
return None


@dataclass(frozen=True)
class ReceiverDiagnosis:
category: str
http_status: int | None = None
body_shape: str = "none"
receiver_error: str | None = None
cloudflare_code: int | None = None


def _json_body_shape(raw: bytes, headers) -> tuple[str, str | None, int | None]:
if len(raw) > _MAX_BODY_BYTES:
return "oversized", None, None
if not raw:
return "empty", None, None
try:
def unique_object(pairs):
result = {}
for key, value in pairs:
if key in result:
raise ValueError("duplicate JSON key")
result[key] = value
return result

body = json.loads(
raw.decode("utf-8"),
object_pairs_hook=unique_object,
parse_constant=lambda _value: (_ for _ in ()).throw(ValueError("invalid JSON constant")),
)
except (UnicodeError, ValueError, RecursionError):
return "invalid_json", None, None
if not isinstance(body, dict):
return "json_non_object", None, None

if (set(body) == _READINESS_FIELDS
and all(type(body.get(key)) is bool for key in _READINESS_FIELDS)):
if all(body[key] is True for key in _READINESS_FIELDS):
return "readiness", None, None
return "readiness_not_ready", None, None

if (set(body) == _READINESS_ERROR_FIELDS
and all(type(body.get(key)) is bool for key in _READINESS_FIELDS)
and body.get("ok") is False and body.get("ready") is False
and isinstance(body.get("error"), str)
and body["error"] in _READINESS_ERROR_CODES):
error = body["error"]
if error in _TOKEN_ERROR_CODES:
return "receiver_error", "token_invalid", None
if error in _BINDING_ERROR_CODES:
return "receiver_error", "binding_unmatched", None
if error in _CONFIG_ERROR_CODES:
return "receiver_error", "config_or_binding", None
if error == "binance_account_facts_account_options_unavailable":
return "receiver_error", "account_options_unavailable", None
return "receiver_error", "receiver_unavailable", None

if (set(body) == {"ok", "error"} and body.get("ok") is False
and isinstance(body.get("error"), str)
and body["error"] in _TOKEN_ERROR_CODES):
return "token_error", "token_invalid", None

cf_code = _cloudflare_error_code(body, headers)
if cf_code is not None:
return "cloudflare_error", None, cf_code
return "json_object_other", None, None


def _cloudflare_error_code(body: dict, headers) -> int | None:
"""Expose only the numeric code from the standard Cloudflare error envelope."""
if str(headers.get("Server", "")).lower() != "cloudflare":
return None
if set(body) != {"success", "errors", "messages", "result"}:
return None
if body.get("success") is not False or body.get("messages") != [] or body.get("result") is not None:
return None
errors = body.get("errors")
if not isinstance(errors, list) or len(errors) != 1 or not isinstance(errors[0], dict):
return None
error = errors[0]
if (set(error) != {"code", "message"} or type(error.get("code")) is not int
or not 0 <= error["code"] <= 999_999 or not isinstance(error.get("message"), str)):
return None
return error["code"]


def _read_once(request: Request, *, opener_factory=build_opener) -> tuple[int, bytes, object]:
try:
with opener_factory(_NoRedirect).open(request, timeout=15) as response:
return response.status, response.read(_MAX_BODY_BYTES + 1), response.headers
except HTTPError as error:
try:
return error.code, error.read(_MAX_BODY_BYTES + 1), error.headers or {}
finally:
error.close()


def diagnose_receiver(*, token: str, opener_factory=build_opener) -> ReceiverDiagnosis:
if not isinstance(token, str) or not token or token != token.strip():
return ReceiverDiagnosis("token_missing")
request = Request(
QRS_ENDPOINT,
headers={"Authorization": f"Bearer {token}", "Accept": "application/json"},
method="GET",
)
try:
status, raw, headers = _read_once(request, opener_factory=opener_factory)
except Exception:
return ReceiverDiagnosis("transport")
body_shape, safe_code, cf_code = _json_body_shape(raw, headers)
if status == 200 and body_shape == "readiness":
return ReceiverDiagnosis("ready", status, body_shape)
if safe_code:
return ReceiverDiagnosis(safe_code, status, body_shape, safe_code)
if cf_code is not None:
return ReceiverDiagnosis("cloudflare_rejected", status, body_shape, cloudflare_code=cf_code)
if 300 <= status <= 399:
return ReceiverDiagnosis("redirect", status, body_shape)
if 100 <= status <= 599:
return ReceiverDiagnosis("http_rejected", status, body_shape)
return ReceiverDiagnosis("response_invalid", body_shape=body_shape)


def main() -> int:
result = diagnose_receiver(token=os.environ.get("BINANCE_ACCOUNT_FACTS_SYNC_TOKEN", ""))
fields = [f"category={result.category}"]
if result.http_status is not None:
fields.append(f"http_status={result.http_status}")
fields.append(f"body_shape={result.body_shape}")
if result.receiver_error is not None:
fields.append(f"receiver_code={result.receiver_error}")
if result.cloudflare_code is not None:
fields.append(f"cloudflare_code={result.cloudflare_code}")
print("binance_account_facts_receiver_diagnosis " + " ".join(fields))
return 0 if result.category == "ready" else 1


if __name__ == "__main__":
raise SystemExit(main())
31 changes: 20 additions & 11 deletions scripts/publish_binance_account_facts.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,20 +47,29 @@ def redirect_request(self, request, response, code, message, headers, new_url):


def _http_failure_code(error: HTTPError) -> str:
status = error.code
if type(status) is not int or not 100 <= status <= 599:
return _HTTP_REJECTED
try:
raw = error.read(4097)
if not isinstance(raw, bytes) or len(raw) > 4096:
return _HTTP_REJECTED
body = json.loads(raw.decode("utf-8"))
if (not isinstance(body, dict) or set(body) != {"ok", "error"}
or body.get("ok") is not False or not isinstance(body.get("error"), str)):
return _HTTP_REJECTED
label = _HTTP_ERROR_LABELS.get((error.code, body["error"]))
if label is None:
return _HTTP_REJECTED
return f"account_facts_publish_{label}"
if not isinstance(raw, bytes):
shape = "invalid_body"
elif len(raw) > 4096:
shape = "body_oversized"
else:
shape = "body_invalid_json"
body = json.loads(raw.decode("utf-8"))
if isinstance(body, dict):
shape = "body_unrecognized_json"
if (set(body) == {"ok", "error"} and body.get("ok") is False
and isinstance(body.get("error"), str)):
shape = "body_unknown_error_code"
label = _HTTP_ERROR_LABELS.get((status, body["error"]))
if label is not None:
return f"account_facts_publish_{label}"
return f"account_facts_publish_http_{status}_{shape}"
except (UnicodeError, ValueError, TypeError, RecursionError):
return _HTTP_REJECTED
return f"account_facts_publish_http_{status}_body_invalid_json"


def publish_account_facts(*, facts_path: Path, env) -> str:
Expand Down
Loading