Skip to content

Commit 2d4023a

Browse files
karlwaldmanclaude
andcommitted
fix(streaming): identify the SDK on the WebSocket handshake
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) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JKAExynd9zoKwt6rYA66EA
1 parent 93aead3 commit 2d4023a

2 files changed

Lines changed: 128 additions & 1 deletion

File tree

‎oilpriceapi/streaming/client.py‎

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
import json
2828
import logging
2929
import random
30+
import sys
3031
from types import TracebackType
3132
from typing import TYPE_CHECKING, Any, AsyncIterator, Dict, List, Optional, Type
3233

@@ -115,7 +116,22 @@ async def connect(self) -> None:
115116
# also accepts the Authorization header (connection.rb find_verified_user).
116117
sep = "&" if "?" in self._cable_url else "?"
117118
url = f"{self._cable_url}{sep}token={self._api_key}"
118-
headers = {"Authorization": f"Token {self._api_key}"}
119+
# Identify the SDK on the handshake exactly as the HTTP client does.
120+
# The upgrade request is an ordinary HTTP request, so without these
121+
# headers a streaming client is indistinguishable from a hand-rolled
122+
# WebSocket and drops out of SDK attribution entirely. The Go SDK
123+
# (stream.go) already sets the User-Agent here.
124+
from ..version import SDK_NAME, SDK_VERSION
125+
126+
python_version = (
127+
f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}"
128+
)
129+
headers = {
130+
"Authorization": f"Token {self._api_key}",
131+
"User-Agent": f"{SDK_NAME}/{SDK_VERSION} python/{python_version}",
132+
"X-SDK-Name": SDK_NAME,
133+
"X-SDK-Version": SDK_VERSION,
134+
}
119135

120136
self._ws = await websockets.connect(
121137
url,

‎tests/unit/test_sdk_attribution.py‎

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
"""Server-side attribution contract for the Python SDK.
2+
3+
`MinimalAnalyticsService#detect_sdk_info` in oilpriceapi-api parses
4+
sdk_language/sdk_version out of the User-Agent with exactly the regex
5+
pinned below. A User-Agent that stops matching it still returns 200 --
6+
the request succeeds and the SDK silently disappears from adoption
7+
reporting. That is a failure no HTTP-level test would catch, so these
8+
tests assert the *parsed* language and version rather than a substring.
9+
10+
Keep SERVER_SDK_REGEX in step with
11+
app/services/minimal_analytics_service.rb (detect_sdk_info).
12+
"""
13+
14+
import re
15+
16+
import pytest
17+
18+
from oilpriceapi import OilPriceAPI
19+
from oilpriceapi.version import SDK_NAME, SDK_VERSION
20+
21+
SERVER_SDK_REGEX = re.compile(
22+
r"oilpriceapi-([a-z0-9-]+)/v?([\d]+\.[\d]+\.?[\d]*)", re.IGNORECASE
23+
)
24+
25+
26+
def _parse(user_agent):
27+
return SERVER_SDK_REGEX.search(user_agent or "")
28+
29+
30+
def test_sdk_name_and_version_are_shaped_for_the_server_regex():
31+
match = _parse(f"{SDK_NAME}/{SDK_VERSION}")
32+
assert match is not None, f"{SDK_NAME}/{SDK_VERSION} does not parse server-side"
33+
assert match.group(1) == "python"
34+
assert match.group(2) == SDK_VERSION
35+
36+
37+
def test_sync_client_user_agent_parses_to_python_and_the_real_version():
38+
client = OilPriceAPI(api_key="test_key")
39+
match = _parse(client.headers.get("User-Agent"))
40+
41+
assert match is not None, "sync client User-Agent is not attributable server-side"
42+
assert match.group(1) == "python"
43+
assert match.group(2) == SDK_VERSION
44+
45+
46+
def test_async_client_user_agent_parses_to_python_and_the_real_version():
47+
from oilpriceapi import AsyncOilPriceAPI
48+
49+
client = AsyncOilPriceAPI(api_key="test_key")
50+
match = _parse(client.headers.get("User-Agent"))
51+
52+
assert match is not None, "async client User-Agent is not attributable server-side"
53+
assert match.group(1) == "python"
54+
assert match.group(2) == SDK_VERSION
55+
56+
57+
@pytest.mark.asyncio
58+
async def test_streaming_handshake_sends_an_attributable_user_agent(monkeypatch):
59+
"""The ActionCable upgrade is an ordinary HTTP request.
60+
61+
Without a User-Agent a streaming client is indistinguishable from a
62+
hand-rolled WebSocket and drops out of SDK attribution entirely. The Go
63+
SDK already sets one on its handshake (stream.go).
64+
"""
65+
from oilpriceapi.streaming import client as streaming_client
66+
67+
captured = {}
68+
69+
class _FakeWS:
70+
"""Scripted ActionCable peer: welcome, then confirm_subscription."""
71+
72+
def __init__(self):
73+
self._frames = iter(
74+
['{"type": "welcome"}', '{"type": "confirm_subscription"}']
75+
)
76+
77+
async def recv(self):
78+
return next(self._frames)
79+
80+
async def send(self, _data):
81+
return None
82+
83+
async def close(self):
84+
return None
85+
86+
async def _fake_connect(url, **kwargs):
87+
captured["url"] = url
88+
captured["headers"] = kwargs.get("additional_headers") or {}
89+
return _FakeWS()
90+
91+
class _FakeWebsockets:
92+
connect = staticmethod(_fake_connect)
93+
94+
monkeypatch.setattr(
95+
streaming_client, "_import_websockets", lambda: _FakeWebsockets
96+
)
97+
98+
stream = streaming_client.PriceStream(
99+
cable_url="wss://api.oilpriceapi.com/cable", api_key="test_key"
100+
)
101+
await stream.connect()
102+
103+
headers = {k.lower(): v for k, v in captured["headers"].items()}
104+
match = _parse(headers.get("user-agent"))
105+
106+
assert match is not None, (
107+
"WebSocket handshake sent no attributable User-Agent; "
108+
f"headers were {sorted(headers)}"
109+
)
110+
assert match.group(1) == "python"
111+
assert match.group(2) == SDK_VERSION

0 commit comments

Comments
 (0)