Skip to content

QuotesClient TLS handshake fails against documented WSS endpoint (Peer sent no certificates to verify) even with correct host/port/transport; gRPC token-refresh leg also intermittently UNAVAILABLE #15

Description

@bgmaynard

Environment

  • webull-python-sdk-core, webull-python-sdk-mdata, webull-python-sdk-quotes-core — 0.1.18 (latest on PyPI)
  • Python 3.13
  • OS: Windows 11
  • grpcio 1.75.1, paho-mqtt 1.6.1

Summary

Using DefaultQuotesClient for market-data streaming (SNAPSHOT/TICK/QUOTE), we have never received a single tick, on any deployment, across multiple debugging sessions. Two separate, stacked issues:

1. QuotesClient's default construction doesn't match your own documented endpoint contract, and even a corrected construction still fails the TLS handshake

Per https://developer.webull.com/apis/docs/market-data-api/data-streaming-api, the two production endpoints are:

  • data-api.webull.com:1883 — plaintext TCP
  • wss://data-api.webull.com:8883/mqttWebSocket over TLS

But QuotesClient.__init__ (webullsdkquotescore/quotes_client.py) defaults transport="tcp" (not "websockets") regardless of tls_enable, and DefaultQuotesClient passes that same "tcp" default straight through. So the SDK's own out-of-the-box defaults (tls_enable=True, mqtt_post=8883, transport="tcp") open a raw TCP+TLS socket against an endpoint that's documented as WebSocket-only — a straightforward protocol mismatch.

However, correcting for this doesn't fix it. We tested the fully docs-correct construction directly against paho (transport="websockets", host=data-api.webull.com, port=8883, TLS on, path defaults to /mqtt in paho already) and got the identical failure:

SSLCertVerificationError: ('Peer sent no certificates to verify',)

This is a TLS-handshake-level failure (raised from do_handshake()), before any MQTT or WebSocket framing is even exchanged — so it isn't specific to the transport mode. To isolate further, in the same process/moment we also ran:

1. socket.create_connection(('data-api.webull.com', 8883)) + ssl.create_default_context().wrap_socket(...)
   -> OK, cert subject *.webull.com

2. Same host:port, but with an SSLContext built to exactly mirror what
   Client.tls_set() constructs internally (ssl.SSLContext(ssl.PROTOCOL_TLS),
   verify_mode=CERT_REQUIRED, load_default_certs(), no ca_certs override)
   -> OK, cert subject *.webull.com

3. paho.mqtt.client.Client(transport="websockets").connect('data-api.webull.com', 8883)
   with the SAME effective SSL context (via Client.tls_set())
   -> FAIL, SSLCertVerificationError: Peer sent no certificates to verify

(1) and (2) rule out host, port, cert chain, TLS version, and the specific SSLContext construction paho uses as the cause. Only path (3) — going through paho.mqtt.client.Client's actual connect()/reconnect() (which does wrap_socket(..., do_handshake_on_connect=False) on a socket that gets set non-blocking, then a separately-timed sock.do_handshake()) — fails. We were not able to fully pin down why this specific code path behaves differently from a synchronous bare-socket handshake with an equivalent context; it may be a paho-mqtt 1.6.1-specific issue (an old, EOL-adjacent library this SDK pins via the six-vendoring compatibility shim) rather than something on WeBull's side, but since this SDK requires that paho version, it produces a 100% reproducible dead-on-arrival TLS failure for every caller who uses the SDK's client class against the documented TLS endpoint.

Workaround applied: route the MQTT leg to data-api.webull.com:1883 (plaintext TCP, not WSS) instead, leaving the gRPC leg on usquotes-api.webullfintech.com:443. This is NOT what your docs describe as the TLS-secured path, but it's the only configuration under which paho's handshake succeeds at all.

2. Even after the above fix, the gRPC leg used for token refresh intermittently fails to connect at all

Independent of #1, requests over the gRPC channel (GetStreamingTokenRequest, used by _quotes_connect before every MQTT connect attempt) intermittently fail:

grpc.RpcError: StatusCode.UNAVAILABLE
details = "failed to connect to all addresses; last error: UNKNOWN: ipv4:X.X.X.X:443:
            connection attempt timed out before receiving SETTINGS frame"

This happens even on a freshly-started process (within 10-30s of a clean boot) and persists for extended periods (observed continuously for 30+ minutes in one instance). During the SAME time window, from the SAME machine:

  • A raw grpc.secure_channel(host, grpc.ssl_channel_credentials()) (no SDK involved) connects and becomes ready in ~0.25s, and successfully completes real unary RPC calls both immediately and after 75s idle.
  • grpcurl usquotes-api.webullfintech.com:443 list connects cleanly and gets a proper structured gRPC response from the server.

So the network path and the server are demonstrably healthy at the exact same time the SDK's own channel cannot connect. We believe this may relate to GrpcApiClient being a process-wide singleton (webullsdkquotescore/grpc/grpc_client.py) whose real connection setup (Connect(...) + .run()) is guarded by if not hasattr(self, '_connect') and therefore runs exactly once per process — if that one-shot setup fails or degrades, no later construction attempt in the same process can trigger a retry, since the guard is satisfied by a self._connect that was assigned before .run() could raise. We could not fully confirm this without instrumenting the SDK internals further, but the symptom (works for a while, or fails immediately, then never recovers without a full process restart) is consistent with it.

Steps to reproduce

  1. Construct DefaultQuotesClient with valid App Key/Secret, tls_enable=True (default), against data-api.webull.com — or even just transport="websockets" directly against paho.mqtt.client.Client with TLS on, hostname data-api.webull.com, port 8883.
  2. Call connect() / connect_and_loop_async().
  3. Observe: on_log (if wired — it is NOT wired by default; paho's _easy_log is a silent no-op unless the caller sets on_log or calls enable_logger(), so this failure is invisible without extra instrumentation) — or a direct try/except around connect() — shows SSLCertVerificationError: Peer sent no certificates to verify.
  4. Compare against a bare socket.create_connection() + ssl.SSLContext(...).wrap_socket() (same host/port, equivalent context) done synchronously in the same process moments before/after — it succeeds every time.
  5. Separately: even after routing the MQTT leg to the plaintext 1883 workaround, on_refresh_token intermittently raises UNAVAILABLE from the shared gRPC leg (usquotes-api.webullfintech.com:443), blocking _quotes_connect entirely — sometimes immediately on a fresh process, sometimes after several successful MQTT connect/disconnect cycles.

Expected behavior

DefaultQuotesClient/connect_and_loop_async() establishes a working MQTT connection and delivers ticks for subscribed symbols.

Actual behavior

Zero ticks ever delivered. Two independent, stacked connection failures as described above.

Additional context

Happy to share full logs/a minimal repro script if useful — wanted to keep this issue focused on the two concrete failure modes we isolated rather than the full debugging trail. This blocked market-data streaming entirely for our use case; we've worked around it with the port-1883 patch for issue #1, but issue #2 remains unresolved and intermittently blocks even that workaround.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions