Skip to content

Commit 7c7cbeb

Browse files
refactor(webhooks): unify webhook errors under InvalidWebhookError (CHA-3071)
Per cross-SDK coordination (mogita's review on the 6 sibling SDK PRs), every webhook failure path now terminates at a single exception class. Customers only need one except arm and can filter by message text for mode-specific behaviour (signature mismatch vs invalid base64 etc.). Renames the previously-unreleased WebhookSignatureError to InvalidWebhookError and threads it through every primitive: verify_signature -> 'signature mismatch' gunzip_payload -> 'gzip decompression failed' decode_sqs_payload -> 'invalid base64 encoding' parse_event -> 'invalid JSON payload' StreamChat#verify_webhook (the legacy bool helper) is untouched. The message constants are exported so callers can exact-match if they prefer that over substring matching. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 69048d8 commit 7c7cbeb

5 files changed

Lines changed: 61 additions & 45 deletions

File tree

docs/webhooks/webhooks_overview/webhooks_overview.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -135,7 +135,7 @@ def stream_webhook():
135135

136136
The same call works whether or not Stream is compressing for this app, and whether or not your framework auto-decompressed the request — the helper inspects the body bytes rather than the `Content-Encoding` header.
137137

138-
All helpers raise `stream_chat.base.exceptions.WebhookSignatureError` when the signature does not match, when the gzip stream is corrupt, or when the SQS/SNS base64 envelope cannot be decoded.
138+
All helpers raise `stream_chat.webhook.InvalidWebhookError` when the signature does not match, when the gzip stream is corrupt, or when the SQS/SNS base64 envelope cannot be decoded.
139139

140140
The original `client.verify_webhook(request.body, request.headers["X-Signature"])` — which returns a `bool` and does not decompress — stays unchanged for backward compatibility. Switch to `verify_and_parse_webhook` to support compressed payloads.
141141

stream_chat/base/client.py

Lines changed: 3 additions & 3 deletions
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.webhook.InvalidWebhookError: on
151151
signature mismatch or any decode error
152152
"""
153153
from stream_chat.webhook import verify_and_parse_webhook
@@ -167,7 +167,7 @@ def verify_and_parse_sqs(
167167
168168
:param message_body: SQS message ``Body`` (string)
169169
:param signature: ``X-Signature`` message attribute value
170-
:raises stream_chat.base.exceptions.WebhookSignatureError: on
170+
:raises stream_chat.webhook.InvalidWebhookError: on
171171
signature mismatch or any decode error
172172
"""
173173
from stream_chat.webhook import verify_and_parse_sqs
@@ -187,7 +187,7 @@ def verify_and_parse_sns(
187187
188188
:param message: SNS notification ``Message`` field (string)
189189
:param signature: ``X-Signature`` message attribute value
190-
:raises stream_chat.base.exceptions.WebhookSignatureError: on
190+
:raises stream_chat.webhook.InvalidWebhookError: on
191191
signature mismatch or any decode error
192192
"""
193193
from stream_chat.webhook import verify_and_parse_sns

stream_chat/base/exceptions.py

Lines changed: 0 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -25,17 +25,3 @@ def __str__(self) -> str:
2525
return f'StreamChat error code {self.error_code}: {self.error_message}"'
2626
else:
2727
return f"StreamChat error HTTP code: {self.status_code}"
28-
29-
30-
class WebhookSignatureError(StreamAPIException):
31-
"""Raised when an outbound webhook signature does not match, the
32-
webhook payload cannot be decompressed, or the wrapping (e.g. base64)
33-
cannot be decoded.
34-
"""
35-
36-
def __init__(self, message: str) -> None:
37-
super().__init__(message, status_code=0)
38-
self.message = message
39-
40-
def __str__(self) -> str:
41-
return f"WebhookSignatureError: {self.message}"

stream_chat/tests/test_webhook_compression.py

Lines changed: 23 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -27,9 +27,9 @@
2727
import pytest
2828

2929
from stream_chat import StreamChat, StreamChatAsync
30-
from stream_chat.base.exceptions import WebhookSignatureError
3130
from stream_chat.webhook import (
3231
GZIP_MAGIC,
32+
InvalidWebhookError,
3333
decode_sns_payload,
3434
decode_sqs_payload,
3535
gunzip_payload,
@@ -88,9 +88,13 @@ def test_short_input_below_magic_length(self):
8888

8989
def test_truncated_gzip_with_magic_raises(self):
9090
bad = GZIP_MAGIC + b"\x00\x00\x00"
91-
with pytest.raises(WebhookSignatureError) as exc_info:
91+
with pytest.raises(InvalidWebhookError, match=r"gzip decompression failed"):
9292
gunzip_payload(bad)
93-
assert "decompress" in str(exc_info.value).lower()
93+
94+
def test_gunzip_payload_raises_on_corrupt_gzip(self):
95+
corrupt = GZIP_MAGIC + b"\x08\x00" + b"\x00" * 20
96+
with pytest.raises(InvalidWebhookError, match=r"gzip decompression failed"):
97+
gunzip_payload(corrupt)
9498

9599
def test_decompresses_helloworld_fixture(self):
96100
gz_bytes = base64.b64decode("H4sIAGrYAWoAA8tIzcnJL88vykkBAK0g6/kKAAAA")
@@ -114,9 +118,12 @@ def test_accepts_bytes_input(self):
114118
assert decode_sqs_payload(encoded) == JSON_BODY
115119

116120
def test_invalid_base64_raises(self):
117-
with pytest.raises(WebhookSignatureError) as exc_info:
121+
with pytest.raises(InvalidWebhookError, match=r"invalid base64 encoding"):
118122
decode_sqs_payload("!!!not-valid-base64!!!")
119-
assert "base64" in str(exc_info.value).lower()
123+
124+
def test_decode_sqs_payload_raises_on_invalid_base64(self):
125+
with pytest.raises(InvalidWebhookError, match=r"invalid base64 encoding"):
126+
decode_sqs_payload("not*valid*base64*data")
120127

121128
def test_decodes_helloworld_base64_fixture(self):
122129
assert decode_sqs_payload("aGVsbG93b3JsZA==") == b"helloworld"
@@ -208,8 +215,8 @@ def test_unknown_event_type_still_parses(self):
208215
body = b'{"type":"a.future.event","custom":42}'
209216
assert parse_event(body) == {"type": "a.future.event", "custom": 42}
210217

211-
def test_malformed_json_raises(self):
212-
with pytest.raises(json.JSONDecodeError):
218+
def test_parse_event_raises_on_invalid_json(self):
219+
with pytest.raises(InvalidWebhookError, match=r"invalid JSON payload"):
213220
parse_event(b"not json")
214221

215222

@@ -228,29 +235,28 @@ def test_returns_dict(self):
228235
assert isinstance(result, dict)
229236

230237
def test_signature_mismatch_raises(self):
231-
with pytest.raises(WebhookSignatureError) as exc_info:
238+
with pytest.raises(InvalidWebhookError, match=r"signature mismatch"):
232239
verify_and_parse_webhook(JSON_BODY, "0" * 64, API_SECRET)
233-
assert "invalid webhook signature" in str(exc_info.value).lower()
234240

235241
def test_signature_must_be_over_uncompressed_bytes(self):
236242
compressed = _gzip(JSON_BODY)
237243
sig_over_compressed = _sign(compressed)
238-
with pytest.raises(WebhookSignatureError):
244+
with pytest.raises(InvalidWebhookError, match=r"signature mismatch"):
239245
verify_and_parse_webhook(compressed, sig_over_compressed, API_SECRET)
240246

241247
def test_wrong_secret_raises(self):
242248
sig = _sign(JSON_BODY, secret="other")
243-
with pytest.raises(WebhookSignatureError):
249+
with pytest.raises(InvalidWebhookError, match=r"signature mismatch"):
244250
verify_and_parse_webhook(JSON_BODY, sig, API_SECRET)
245251

246252
def test_signature_can_be_bytes(self):
247253
sig = _sign(JSON_BODY).encode()
248254
assert verify_and_parse_webhook(JSON_BODY, sig, API_SECRET) == EVENT_DICT
249255

250256
def test_malformed_signature_surfaces_as_webhook_error(self):
251-
with pytest.raises(WebhookSignatureError):
257+
with pytest.raises(InvalidWebhookError, match=r"signature mismatch"):
252258
verify_and_parse_webhook(JSON_BODY, b"\xff" * 32, API_SECRET)
253-
with pytest.raises(WebhookSignatureError):
259+
with pytest.raises(InvalidWebhookError, match=r"signature mismatch"):
254260
verify_and_parse_webhook(JSON_BODY, "\u2603" * 64, API_SECRET)
255261

256262

@@ -267,13 +273,13 @@ def test_base64_plus_gzip(self):
267273

268274
def test_signature_mismatch_raises(self):
269275
wrapped = _b64(_gzip(JSON_BODY))
270-
with pytest.raises(WebhookSignatureError):
276+
with pytest.raises(InvalidWebhookError, match=r"signature mismatch"):
271277
verify_and_parse_sqs(wrapped, "0" * 64, API_SECRET)
272278

273279
def test_signature_over_compressed_or_wrapped_bytes_rejected(self):
274280
wrapped = _b64(_gzip(JSON_BODY))
275281
sig_over_wrapped = _sign(wrapped.encode("ascii"))
276-
with pytest.raises(WebhookSignatureError):
282+
with pytest.raises(InvalidWebhookError, match=r"signature mismatch"):
277283
verify_and_parse_sqs(wrapped, sig_over_wrapped, API_SECRET)
278284

279285

@@ -300,7 +306,7 @@ def test_rejects_signature_over_envelope(self):
300306
wrapped = _b64(_gzip(JSON_BODY))
301307
envelope = _sns_envelope(wrapped)
302308
sig_over_envelope = _sign(envelope.encode("utf-8"))
303-
with pytest.raises(WebhookSignatureError):
309+
with pytest.raises(InvalidWebhookError, match=r"signature mismatch"):
304310
verify_and_parse_sns(envelope, sig_over_envelope, API_SECRET)
305311

306312

@@ -320,7 +326,7 @@ def test_verify_and_parse_sns(self, sync_client: StreamChat):
320326
assert sync_client.verify_and_parse_sns(wrapped, sig) == EVENT_DICT
321327

322328
def test_signature_mismatch_via_client(self, sync_client: StreamChat):
323-
with pytest.raises(WebhookSignatureError):
329+
with pytest.raises(InvalidWebhookError, match=r"signature mismatch"):
324330
sync_client.verify_and_parse_webhook(JSON_BODY, "0" * 64)
325331

326332

stream_chat/webhook.py

Lines changed: 34 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -19,12 +19,33 @@
1919
import hashlib
2020
import hmac
2121
import json
22+
import zlib
2223
from typing import Any, Dict, Optional, Union
2324

24-
from stream_chat.base.exceptions import WebhookSignatureError
25-
2625
GZIP_MAGIC = b"\x1f\x8b"
2726

27+
INVALID_WEBHOOK_SIGNATURE_MISMATCH = "signature mismatch"
28+
INVALID_WEBHOOK_INVALID_BASE64 = "invalid base64 encoding"
29+
INVALID_WEBHOOK_GZIP_FAILED = "gzip decompression failed"
30+
INVALID_WEBHOOK_INVALID_JSON = "invalid JSON payload"
31+
32+
33+
class InvalidWebhookError(Exception):
34+
"""Raised by every webhook primitive when verification or decoding
35+
fails. The cross-SDK contract is "one exception, message says why" -
36+
callers branch on the message text when they need mode-specific
37+
behaviour (signature mismatch vs invalid base64 vs corrupt gzip vs
38+
malformed JSON).
39+
"""
40+
41+
def __init__(self, message: str) -> None:
42+
super().__init__(message)
43+
self.message = message
44+
45+
def __str__(self) -> str:
46+
return f"InvalidWebhookError: {self.message}"
47+
48+
2849
_BytesLike = Union[bytes, bytearray, memoryview, str]
2950

3051

@@ -49,8 +70,8 @@ def gunzip_payload(body: _BytesLike) -> bytes:
4970
return raw
5071
try:
5172
return gzip.decompress(raw)
52-
except (gzip.BadGzipFile, OSError, EOFError) as exc:
53-
raise WebhookSignatureError(f"failed to decompress gzip payload: {exc}")
73+
except (gzip.BadGzipFile, OSError, EOFError, zlib.error) as err:
74+
raise InvalidWebhookError(INVALID_WEBHOOK_GZIP_FAILED) from err
5475

5576

5677
def decode_sqs_payload(body: _BytesLike) -> bytes:
@@ -65,8 +86,8 @@ def decode_sqs_payload(body: _BytesLike) -> bytes:
6586
raw = _to_bytes(body)
6687
try:
6788
decoded = base64.b64decode(raw, validate=True)
68-
except ValueError as exc:
69-
raise WebhookSignatureError(f"failed to base64-decode payload: {exc}")
89+
except ValueError as err:
90+
raise InvalidWebhookError(INVALID_WEBHOOK_INVALID_BASE64) from err
7091
return gunzip_payload(decoded)
7192

7293

@@ -141,8 +162,11 @@ def parse_event(payload: _BytesLike) -> Dict[str, Any]:
141162
without changing call sites.
142163
"""
143164
if isinstance(payload, (bytes, bytearray, memoryview)):
144-
return json.loads(bytes(payload))
145-
return json.loads(payload)
165+
payload = bytes(payload)
166+
try:
167+
return json.loads(payload)
168+
except json.JSONDecodeError as err:
169+
raise InvalidWebhookError(INVALID_WEBHOOK_INVALID_JSON) from err
146170

147171

148172
def _verify_and_parse(
@@ -151,7 +175,7 @@ def _verify_and_parse(
151175
secret: str,
152176
) -> Dict[str, Any]:
153177
if not verify_signature(payload_bytes, signature, secret):
154-
raise WebhookSignatureError("invalid webhook signature")
178+
raise InvalidWebhookError(INVALID_WEBHOOK_SIGNATURE_MISMATCH)
155179
return parse_event(payload_bytes)
156180

157181

@@ -166,7 +190,7 @@ def verify_and_parse_webhook(
166190
:param body: raw HTTP request body bytes Stream signed
167191
:param signature: ``X-Signature`` header value
168192
:param secret: the app's API secret
169-
:raises WebhookSignatureError: on signature mismatch or decode error
193+
:raises InvalidWebhookError: on signature mismatch or any decode error
170194
"""
171195
inflated = gunzip_payload(body)
172196
return _verify_and_parse(inflated, signature, secret)

0 commit comments

Comments
 (0)