From 2d4023a0e2f866ab6004ebfd1791a4eb2d546b97 Mon Sep 17 00:00:00 2001 From: Karl Waldman Date: Sat, 22 Aug 2026 09:47:57 -0400 Subject: [PATCH] fix(streaming): identify the SDK on the WebSocket handshake MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ActionCable upgrade request is an ordinary HTTP request, but the streaming client sent only `Authorization` on it. A streaming connection was therefore indistinguishable from a hand-rolled WebSocket and dropped out of SDK attribution entirely, while the HTTP path has always been attributed correctly. The Go SDK already sets a User-Agent on its handshake (stream.go); this brings Python into line and adds the X-SDK-* pair the HTTP path sends. Also pins the server-side attribution contract from the SDK side. The server parses sdk_language/sdk_version out of the User-Agent with `/oilpriceapi-([a-z0-9-]+)\/v?([\d]+\.[\d]+\.?[\d]*)/i` (MinimalAnalyticsService#detect_sdk_info). A UA that stops matching it still returns 200 — the request succeeds and the SDK silently vanishes from adoption reporting. Nothing on either side guarded that shape, so the new tests assert the parsed language and version, not just a substring, across the sync, async and streaming paths. Proven red-capable: changing SDK_NAME to `oilpriceapi_python` fails all four; the streaming test failed before the fix (headers were ['authorization']) and passes after. Verified: 539 passed, 13 skipped. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01JKAExynd9zoKwt6rYA66EA --- oilpriceapi/streaming/client.py | 18 ++++- tests/unit/test_sdk_attribution.py | 111 +++++++++++++++++++++++++++++ 2 files changed, 128 insertions(+), 1 deletion(-) create mode 100644 tests/unit/test_sdk_attribution.py diff --git a/oilpriceapi/streaming/client.py b/oilpriceapi/streaming/client.py index 6ebe55e..16f4f32 100644 --- a/oilpriceapi/streaming/client.py +++ b/oilpriceapi/streaming/client.py @@ -27,6 +27,7 @@ import json import logging import random +import sys from types import TracebackType from typing import TYPE_CHECKING, Any, AsyncIterator, Dict, List, Optional, Type @@ -115,7 +116,22 @@ async def connect(self) -> None: # also accepts the Authorization header (connection.rb find_verified_user). sep = "&" if "?" in self._cable_url else "?" url = f"{self._cable_url}{sep}token={self._api_key}" - headers = {"Authorization": f"Token {self._api_key}"} + # Identify the SDK on the handshake exactly as the HTTP client does. + # The upgrade request is an ordinary HTTP request, so without these + # headers a streaming client is indistinguishable from a hand-rolled + # WebSocket and drops out of SDK attribution entirely. The Go SDK + # (stream.go) already sets the User-Agent here. + from ..version import SDK_NAME, SDK_VERSION + + python_version = ( + f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}" + ) + headers = { + "Authorization": f"Token {self._api_key}", + "User-Agent": f"{SDK_NAME}/{SDK_VERSION} python/{python_version}", + "X-SDK-Name": SDK_NAME, + "X-SDK-Version": SDK_VERSION, + } self._ws = await websockets.connect( url, diff --git a/tests/unit/test_sdk_attribution.py b/tests/unit/test_sdk_attribution.py new file mode 100644 index 0000000..a87b1f7 --- /dev/null +++ b/tests/unit/test_sdk_attribution.py @@ -0,0 +1,111 @@ +"""Server-side attribution contract for the Python SDK. + +`MinimalAnalyticsService#detect_sdk_info` in oilpriceapi-api parses +sdk_language/sdk_version out of the User-Agent with exactly the regex +pinned below. A User-Agent that stops matching it still returns 200 -- +the request succeeds and the SDK silently disappears from adoption +reporting. That is a failure no HTTP-level test would catch, so these +tests assert the *parsed* language and version rather than a substring. + +Keep SERVER_SDK_REGEX in step with +app/services/minimal_analytics_service.rb (detect_sdk_info). +""" + +import re + +import pytest + +from oilpriceapi import OilPriceAPI +from oilpriceapi.version import SDK_NAME, SDK_VERSION + +SERVER_SDK_REGEX = re.compile( + r"oilpriceapi-([a-z0-9-]+)/v?([\d]+\.[\d]+\.?[\d]*)", re.IGNORECASE +) + + +def _parse(user_agent): + return SERVER_SDK_REGEX.search(user_agent or "") + + +def test_sdk_name_and_version_are_shaped_for_the_server_regex(): + match = _parse(f"{SDK_NAME}/{SDK_VERSION}") + assert match is not None, f"{SDK_NAME}/{SDK_VERSION} does not parse server-side" + assert match.group(1) == "python" + assert match.group(2) == SDK_VERSION + + +def test_sync_client_user_agent_parses_to_python_and_the_real_version(): + client = OilPriceAPI(api_key="test_key") + match = _parse(client.headers.get("User-Agent")) + + assert match is not None, "sync client User-Agent is not attributable server-side" + assert match.group(1) == "python" + assert match.group(2) == SDK_VERSION + + +def test_async_client_user_agent_parses_to_python_and_the_real_version(): + from oilpriceapi import AsyncOilPriceAPI + + client = AsyncOilPriceAPI(api_key="test_key") + match = _parse(client.headers.get("User-Agent")) + + assert match is not None, "async client User-Agent is not attributable server-side" + assert match.group(1) == "python" + assert match.group(2) == SDK_VERSION + + +@pytest.mark.asyncio +async def test_streaming_handshake_sends_an_attributable_user_agent(monkeypatch): + """The ActionCable upgrade is an ordinary HTTP request. + + Without a User-Agent a streaming client is indistinguishable from a + hand-rolled WebSocket and drops out of SDK attribution entirely. The Go + SDK already sets one on its handshake (stream.go). + """ + from oilpriceapi.streaming import client as streaming_client + + captured = {} + + class _FakeWS: + """Scripted ActionCable peer: welcome, then confirm_subscription.""" + + def __init__(self): + self._frames = iter( + ['{"type": "welcome"}', '{"type": "confirm_subscription"}'] + ) + + async def recv(self): + return next(self._frames) + + async def send(self, _data): + return None + + async def close(self): + return None + + async def _fake_connect(url, **kwargs): + captured["url"] = url + captured["headers"] = kwargs.get("additional_headers") or {} + return _FakeWS() + + class _FakeWebsockets: + connect = staticmethod(_fake_connect) + + monkeypatch.setattr( + streaming_client, "_import_websockets", lambda: _FakeWebsockets + ) + + stream = streaming_client.PriceStream( + cable_url="wss://api.oilpriceapi.com/cable", api_key="test_key" + ) + await stream.connect() + + headers = {k.lower(): v for k, v in captured["headers"].items()} + match = _parse(headers.get("user-agent")) + + assert match is not None, ( + "WebSocket handshake sent no attributable User-Agent; " + f"headers were {sorted(headers)}" + ) + assert match.group(1) == "python" + assert match.group(2) == SDK_VERSION