Skip to content

Commit 28d8015

Browse files
feat(webhooks): align cross-SDK contract — InvalidWebhookError + gunzip_payload
- Rename WebhookSignatureError → InvalidWebhookError - Rename ungzip_payload → gunzip_payload - Align error messages to documented strings: signature mismatch / invalid base64 encoding / gzip decompression failed / invalid JSON payload - parse_event now wraps json.JSONDecodeError as InvalidWebhookError - Export INVALID_WEBHOOK_* constants for exact-match filtering
1 parent 08b893d commit 28d8015

4 files changed

Lines changed: 69 additions & 40 deletions

File tree

stream_chat/base/client.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -147,7 +147,7 @@ def verify_and_parse_webhook(
147147
148148
:param body: raw HTTP request body bytes Stream signed
149149
:param signature: ``X-Signature`` header value
150-
:raises stream_chat.base.exceptions.WebhookSignatureError: on
150+
:raises stream_chat.base.exceptions.InvalidWebhookError: on
151151
signature mismatch or any decode error
152152
"""
153153
from stream_chat.webhook import verify_and_parse_webhook

stream_chat/base/exceptions.py

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,21 @@ class StreamChannelException(Exception):
66
pass
77

88

9-
class WebhookSignatureError(Exception):
10-
"""Invalid webhook signature or malformed gzip/base64 envelope."""
9+
class InvalidWebhookError(Exception):
10+
"""Invalid webhook signature or malformed gzip/base64/JSON envelope.
11+
12+
Raised by :mod:`stream_chat.webhook` on any failure path: signature
13+
mismatch, malformed base64, gzip decompression failure, or invalid
14+
JSON payload. The message text identifies the failure mode so
15+
callers that want to differentiate (security logging, retry policy)
16+
can filter on substring or on the module-level constants.
17+
"""
18+
19+
20+
INVALID_WEBHOOK_SIGNATURE_MISMATCH = "signature mismatch"
21+
INVALID_WEBHOOK_INVALID_BASE64 = "invalid base64 encoding"
22+
INVALID_WEBHOOK_GZIP_FAILED = "gzip decompression failed"
23+
INVALID_WEBHOOK_INVALID_JSON = "invalid JSON payload"
1124

1225

1326
class StreamAPIException(Exception):

stream_chat/tests/test_webhook_compression.py

Lines changed: 33 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
55
* Module-level functions in :mod:`stream_chat.webhook`:
66
7-
* primitives - ``ungzip_payload``, ``decode_sqs_payload``,
7+
* primitives - ``gunzip_payload``, ``decode_sqs_payload``,
88
``decode_sns_payload``, ``verify_signature``, ``parse_event``
99
* composite helpers - ``verify_and_parse_webhook``, ``parse_sqs``,
1010
``parse_sns``
@@ -27,15 +27,21 @@
2727
import pytest
2828

2929
from stream_chat import StreamChat, StreamChatAsync
30-
from stream_chat.base.exceptions import WebhookSignatureError
30+
from stream_chat.base.exceptions import (
31+
INVALID_WEBHOOK_GZIP_FAILED,
32+
INVALID_WEBHOOK_INVALID_BASE64,
33+
INVALID_WEBHOOK_INVALID_JSON,
34+
INVALID_WEBHOOK_SIGNATURE_MISMATCH,
35+
InvalidWebhookError,
36+
)
3137
from stream_chat.webhook import (
3238
GZIP_MAGIC,
3339
decode_sns_payload,
3440
decode_sqs_payload,
41+
gunzip_payload,
3542
parse_event,
3643
parse_sns,
3744
parse_sqs,
38-
ungzip_payload,
3945
verify_and_parse_webhook,
4046
verify_signature,
4147
)
@@ -66,31 +72,31 @@ def sync_client() -> StreamChat:
6672
return StreamChat(api_key=API_KEY, api_secret=API_SECRET)
6773

6874

69-
class TestUngzipPayload:
75+
class TestGunzipPayload:
7076
def test_passthrough_plain_bytes(self):
71-
assert ungzip_payload(JSON_BODY) == JSON_BODY
77+
assert gunzip_payload(JSON_BODY) == JSON_BODY
7278

7379
def test_passthrough_str_input(self):
74-
assert ungzip_payload(JSON_BODY.decode("utf-8")) == JSON_BODY
80+
assert gunzip_payload(JSON_BODY.decode("utf-8")) == JSON_BODY
7581

7682
def test_inflates_gzip_bytes(self):
77-
assert ungzip_payload(_gzip(JSON_BODY)) == JSON_BODY
83+
assert gunzip_payload(_gzip(JSON_BODY)) == JSON_BODY
7884

7985
def test_returns_bytes(self):
80-
assert isinstance(ungzip_payload(JSON_BODY), bytes)
81-
assert isinstance(ungzip_payload(_gzip(JSON_BODY)), bytes)
86+
assert isinstance(gunzip_payload(JSON_BODY), bytes)
87+
assert isinstance(gunzip_payload(_gzip(JSON_BODY)), bytes)
8288

8389
def test_empty_input(self):
84-
assert ungzip_payload(b"") == b""
90+
assert gunzip_payload(b"") == b""
8591

8692
def test_short_input_below_magic_length(self):
87-
assert ungzip_payload(b"ab") == b"ab"
93+
assert gunzip_payload(b"ab") == b"ab"
8894

8995
def test_truncated_gzip_with_magic_raises(self):
9096
bad = GZIP_MAGIC + b"\x00\x00\x00"
91-
with pytest.raises(WebhookSignatureError) as exc_info:
92-
ungzip_payload(bad)
93-
assert "decompress" in str(exc_info.value).lower()
97+
with pytest.raises(InvalidWebhookError) as exc_info:
98+
gunzip_payload(bad)
99+
assert str(exc_info.value) == INVALID_WEBHOOK_GZIP_FAILED
94100

95101

96102
class TestDecodeSqsPayload:
@@ -110,9 +116,9 @@ def test_accepts_bytes_input(self):
110116
assert decode_sqs_payload(encoded) == JSON_BODY
111117

112118
def test_invalid_base64_raises(self):
113-
with pytest.raises(WebhookSignatureError) as exc_info:
119+
with pytest.raises(InvalidWebhookError) as exc_info:
114120
decode_sqs_payload("!!!not-valid-base64!!!")
115-
assert "base64" in str(exc_info.value).lower()
121+
assert str(exc_info.value) == INVALID_WEBHOOK_INVALID_BASE64
116122

117123

118124
def _sns_envelope(inner_message: str) -> str:
@@ -178,7 +184,7 @@ def test_non_ascii_bytes_signature_returns_false(self):
178184
assert verify_signature(JSON_BODY, b"\xff" * 32, API_SECRET) is False
179185

180186
def test_non_ascii_str_signature_returns_false(self):
181-
assert verify_signature(JSON_BODY, "\u2603" * 64, API_SECRET) is False
187+
assert verify_signature(JSON_BODY, "" * 64, API_SECRET) is False
182188

183189
def test_non_string_signature_returns_false(self):
184190
assert verify_signature(JSON_BODY, 12345, API_SECRET) is False # type: ignore[arg-type]
@@ -196,8 +202,9 @@ def test_unknown_event_type_still_parses(self):
196202
assert parse_event(body) == {"type": "a.future.event", "custom": 42}
197203

198204
def test_malformed_json_raises(self):
199-
with pytest.raises(json.JSONDecodeError):
205+
with pytest.raises(InvalidWebhookError) as exc_info:
200206
parse_event(b"not json")
207+
assert str(exc_info.value) == INVALID_WEBHOOK_INVALID_JSON
201208

202209

203210
class TestVerifyAndParseWebhook:
@@ -215,30 +222,30 @@ def test_returns_dict(self):
215222
assert isinstance(result, dict)
216223

217224
def test_signature_mismatch_raises(self):
218-
with pytest.raises(WebhookSignatureError) as exc_info:
225+
with pytest.raises(InvalidWebhookError) as exc_info:
219226
verify_and_parse_webhook(JSON_BODY, "0" * 64, API_SECRET)
220-
assert "invalid webhook signature" in str(exc_info.value).lower()
227+
assert str(exc_info.value) == INVALID_WEBHOOK_SIGNATURE_MISMATCH
221228

222229
def test_signature_must_be_over_uncompressed_bytes(self):
223230
compressed = _gzip(JSON_BODY)
224231
sig_over_compressed = _sign(compressed)
225-
with pytest.raises(WebhookSignatureError):
232+
with pytest.raises(InvalidWebhookError):
226233
verify_and_parse_webhook(compressed, sig_over_compressed, API_SECRET)
227234

228235
def test_wrong_secret_raises(self):
229236
sig = _sign(JSON_BODY, secret="other")
230-
with pytest.raises(WebhookSignatureError):
237+
with pytest.raises(InvalidWebhookError):
231238
verify_and_parse_webhook(JSON_BODY, sig, API_SECRET)
232239

233240
def test_signature_can_be_bytes(self):
234241
sig = _sign(JSON_BODY).encode()
235242
assert verify_and_parse_webhook(JSON_BODY, sig, API_SECRET) == EVENT_DICT
236243

237244
def test_malformed_signature_surfaces_as_webhook_error(self):
238-
with pytest.raises(WebhookSignatureError):
245+
with pytest.raises(InvalidWebhookError):
239246
verify_and_parse_webhook(JSON_BODY, b"\xff" * 32, API_SECRET)
240-
with pytest.raises(WebhookSignatureError):
241-
verify_and_parse_webhook(JSON_BODY, "\u2603" * 64, API_SECRET)
247+
with pytest.raises(InvalidWebhookError):
248+
verify_and_parse_webhook(JSON_BODY, "" * 64, API_SECRET)
242249

243250

244251
class TestParseSqs:
@@ -280,7 +287,7 @@ def test_parse_sns(self, sync_client: StreamChat):
280287
assert sync_client.parse_sns(wrapped) == EVENT_DICT
281288

282289
def test_signature_mismatch_via_client(self, sync_client: StreamChat):
283-
with pytest.raises(WebhookSignatureError):
290+
with pytest.raises(InvalidWebhookError):
284291
sync_client.verify_and_parse_webhook(JSON_BODY, "0" * 64)
285292

286293

stream_chat/webhook.py

Lines changed: 20 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,13 @@
2020
import json
2121
from typing import Any, Dict, Optional, Union
2222

23-
from stream_chat.base.exceptions import WebhookSignatureError
23+
from stream_chat.base.exceptions import (
24+
INVALID_WEBHOOK_GZIP_FAILED,
25+
INVALID_WEBHOOK_INVALID_BASE64,
26+
INVALID_WEBHOOK_INVALID_JSON,
27+
INVALID_WEBHOOK_SIGNATURE_MISMATCH,
28+
InvalidWebhookError,
29+
)
2430

2531
GZIP_MAGIC = b"\x1f\x8b"
2632

@@ -35,7 +41,7 @@ def _to_bytes(body: _BytesLike) -> bytes:
3541
raise TypeError(f"webhook body must be bytes or str, got {type(body).__name__}")
3642

3743

38-
def ungzip_payload(body: _BytesLike) -> bytes:
44+
def gunzip_payload(body: _BytesLike) -> bytes:
3945
"""Return ``body`` unchanged unless it starts with the gzip magic
4046
(``1f 8b``, per RFC 1952), in which case the gzip stream is decompressed.
4147
@@ -49,7 +55,7 @@ def ungzip_payload(body: _BytesLike) -> bytes:
4955
try:
5056
return gzip.decompress(raw)
5157
except (gzip.BadGzipFile, OSError, EOFError) as exc:
52-
raise WebhookSignatureError(f"failed to decompress gzip payload: {exc}")
58+
raise InvalidWebhookError(INVALID_WEBHOOK_GZIP_FAILED) from exc
5359

5460

5561
def decode_sqs_payload(body: _BytesLike) -> bytes:
@@ -65,8 +71,8 @@ def decode_sqs_payload(body: _BytesLike) -> bytes:
6571
try:
6672
decoded = base64.b64decode(raw, validate=True)
6773
except ValueError as exc:
68-
raise WebhookSignatureError(f"failed to base64-decode payload: {exc}")
69-
return ungzip_payload(decoded)
74+
raise InvalidWebhookError(INVALID_WEBHOOK_INVALID_BASE64) from exc
75+
return gunzip_payload(decoded)
7076

7177

7278
def decode_sns_payload(notification_body: _BytesLike) -> bytes:
@@ -139,9 +145,12 @@ def parse_event(payload: _BytesLike) -> Dict[str, Any]:
139145
documented primitive so callers can swap in a typed parser later
140146
without changing call sites.
141147
"""
142-
if isinstance(payload, (bytes, bytearray, memoryview)):
143-
return json.loads(bytes(payload))
144-
return json.loads(payload)
148+
try:
149+
if isinstance(payload, (bytes, bytearray, memoryview)):
150+
return json.loads(bytes(payload))
151+
return json.loads(payload)
152+
except (json.JSONDecodeError, ValueError) as exc:
153+
raise InvalidWebhookError(INVALID_WEBHOOK_INVALID_JSON) from exc
145154

146155

147156
def _verify_and_parse(
@@ -150,7 +159,7 @@ def _verify_and_parse(
150159
secret: str,
151160
) -> Dict[str, Any]:
152161
if not verify_signature(payload_bytes, signature, secret):
153-
raise WebhookSignatureError("invalid webhook signature")
162+
raise InvalidWebhookError(INVALID_WEBHOOK_SIGNATURE_MISMATCH)
154163
return parse_event(payload_bytes)
155164

156165

@@ -165,9 +174,9 @@ def verify_and_parse_webhook(
165174
:param body: raw HTTP request body bytes Stream signed
166175
:param signature: ``X-Signature`` header value
167176
:param secret: the app's API secret
168-
:raises WebhookSignatureError: on signature mismatch or decode error
177+
:raises InvalidWebhookError: on signature mismatch or decode error
169178
"""
170-
inflated = ungzip_payload(body)
179+
inflated = gunzip_payload(body)
171180
return _verify_and_parse(inflated, signature, secret)
172181

173182

0 commit comments

Comments
 (0)