Skip to content

Commit fefea4f

Browse files
refactor(webhooks): unify webhook errors under InvalidWebhookException (CHA-3071)
Per cross-SDK coordination (mogita's review on all 6 sibling SDK PRs), every webhook failure path now terminates at a single exception class. Customers only need one catch arm and can filter by getMessage() text for mode-specific behaviour. Renames the previously-unreleased WebhookSignatureException to InvalidWebhookException (still extends StreamException) and threads it through every primitive: verifyAndParseWebhook -> 'signature mismatch' gunzipPayload -> 'gzip decompression failed' decodeSqsPayload -> 'invalid base64 encoding' parseEvent -> 'invalid JSON payload' verifySignature keeps its boolean return at the primitive layer; the composite verifyAndParse* helpers throw on mismatch. The legacy Client#verifyWebhook helper (bool return) is untouched. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 073ae73 commit fefea4f

6 files changed

Lines changed: 102 additions & 56 deletions

File tree

build.gradle

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ dependencies {
3939
runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.5'
4040
runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.5'
4141
testImplementation 'org.junit.jupiter:junit-jupiter-engine:5.8.2'
42+
testImplementation 'org.assertj:assertj-core:3.27.3'
4243
testImplementation 'org.apache.commons:commons-lang3:3.12.0'
4344
compileOnly 'org.jetbrains:annotations:24.1.0'
4445
}

docs/webhooks/webhooks_overview/webhooks_overview.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -122,7 +122,7 @@ boolean valid = App.verifySignature(json, signature, apiSecret);
122122
Event event = App.parseEvent(json);
123123
```
124124

125-
Detection is done via the gzip magic bytes (`1f 8b`, per RFC 1952), so the same helper stays correct whether or not your HTTP server already decompressed the body for you. Any non-gzip body is passed through unchanged. Malformed gzip envelopes raise an `IllegalStateException`.
125+
Detection is done via the gzip magic bytes (`1f 8b`, per RFC 1952), so the same helper stays correct whether or not your HTTP server already decompressed the body for you. Any non-gzip body is passed through unchanged. Every webhook ingestion primitive (`gunzipPayload`, `decodeSqsPayload`, `decodeSnsPayload`, `parseEvent`, and the `verifyAndParse*` helpers) raises `InvalidWebhookException` on failure — one catch arm covers signature mismatches, malformed gzip envelopes, invalid base64, and invalid JSON; the failure-mode message constants on the exception class let callers branch when needed.
126126

127127
#### SQS / SNS payloads
128128

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
package io.getstream.chat.java.exceptions;
2+
3+
import org.jetbrains.annotations.NotNull;
4+
import org.jetbrains.annotations.Nullable;
5+
6+
/**
7+
* Raised by every webhook ingestion primitive when the request cannot be safely turned into a typed
8+
* event. A single exception type lets handler code use one catch arm and, when needed, branch on
9+
* the failure-mode message constants exposed here.
10+
*/
11+
public class InvalidWebhookException extends StreamException {
12+
private static final long serialVersionUID = 1L;
13+
14+
public static final String SIGNATURE_MISMATCH = "signature mismatch";
15+
public static final String INVALID_BASE64 = "invalid base64 encoding";
16+
public static final String GZIP_FAILED = "gzip decompression failed";
17+
public static final String INVALID_JSON = "invalid JSON payload";
18+
19+
public InvalidWebhookException(@NotNull String message) {
20+
super(message, (Throwable) null);
21+
}
22+
23+
public InvalidWebhookException(@NotNull String message, @Nullable Throwable cause) {
24+
super(message, cause);
25+
}
26+
}

src/main/java/io/getstream/chat/java/models/App.java

Lines changed: 25 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
import com.fasterxml.jackson.databind.ObjectMapper;
1616
import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
1717
import com.fasterxml.jackson.databind.util.StdDateFormat;
18+
import io.getstream.chat.java.exceptions.InvalidWebhookException;
1819
import io.getstream.chat.java.models.App.AppCheckPushRequestData.AppCheckPushRequest;
1920
import io.getstream.chat.java.models.App.AppCheckSnsRequestData.AppCheckSnsRequest;
2021
import io.getstream.chat.java.models.App.AppCheckSqsRequestData.AppCheckSqsRequest;
@@ -1532,14 +1533,14 @@ public static boolean verifySignature(
15321533
* <p>Magic-byte detection (rather than relying on a header) lets the same handler stay correct
15331534
* when middleware auto-decompresses the request before your code sees it.
15341535
*/
1535-
public static byte[] gunzipPayload(@NotNull byte[] body) {
1536+
public static byte[] gunzipPayload(@NotNull byte[] body) throws InvalidWebhookException {
15361537
if (body.length < 2 || body[0] != GZIP_MAGIC[0] || body[1] != GZIP_MAGIC[1]) {
15371538
return body;
15381539
}
15391540
try (GZIPInputStream in = new GZIPInputStream(new ByteArrayInputStream(body))) {
15401541
return readAll(in);
15411542
} catch (IOException e) {
1542-
throw new IllegalStateException("failed to decompress gzip payload", e);
1543+
throw new InvalidWebhookException(InvalidWebhookException.GZIP_FAILED, e);
15431544
}
15441545
}
15451546

@@ -1551,12 +1552,12 @@ public static byte[] gunzipPayload(@NotNull byte[] body) {
15511552
* @param body the SQS message {@code Body}
15521553
* @return the raw JSON bytes Stream signed
15531554
*/
1554-
public static byte[] decodeSqsPayload(@NotNull String body) {
1555+
public static byte[] decodeSqsPayload(@NotNull String body) throws InvalidWebhookException {
15551556
byte[] decoded;
15561557
try {
15571558
decoded = Base64.getDecoder().decode(body);
15581559
} catch (IllegalArgumentException e) {
1559-
throw new IllegalStateException("failed to base64-decode payload", e);
1560+
throw new InvalidWebhookException(InvalidWebhookException.INVALID_BASE64, e);
15601561
}
15611562
return gunzipPayload(decoded);
15621563
}
@@ -1568,7 +1569,8 @@ public static byte[] decodeSqsPayload(@NotNull String body) {
15681569
* JSON envelope it is treated as the already-extracted {@code Message} string, so call sites that
15691570
* pre-unwrap continue to work.
15701571
*/
1571-
public static byte[] decodeSnsPayload(@NotNull String notificationBody) {
1572+
public static byte[] decodeSnsPayload(@NotNull String notificationBody)
1573+
throws InvalidWebhookException {
15721574
String inner = extractSnsMessage(notificationBody);
15731575
return decodeSqsPayload(inner != null ? inner : notificationBody);
15741576
}
@@ -1623,20 +1625,21 @@ private static ObjectMapper buildWebhookObjectMapper() {
16231625
* unknown enum values are tolerated so the handler stays forward-compatible with new Stream
16241626
* server releases.
16251627
*
1626-
* @throws IllegalStateException when the bytes are not valid JSON
1628+
* @throws InvalidWebhookException when the bytes are not valid JSON
16271629
*/
1628-
public static @NotNull Event parseEvent(@NotNull byte[] payload) {
1630+
public static @NotNull Event parseEvent(@NotNull byte[] payload) throws InvalidWebhookException {
16291631
try {
16301632
return WEBHOOK_OBJECT_MAPPER.readValue(payload, Event.class);
16311633
} catch (IOException e) {
1632-
throw new IllegalStateException("failed to parse webhook event", e);
1634+
throw new InvalidWebhookException(InvalidWebhookException.INVALID_JSON, e);
16331635
}
16341636
}
16351637

16361638
private static @NotNull Event verifyAndParseInternal(
1637-
@NotNull byte[] payload, @NotNull String signature, @NotNull String secret) {
1639+
@NotNull byte[] payload, @NotNull String signature, @NotNull String secret)
1640+
throws InvalidWebhookException {
16381641
if (!verifySignature(payload, signature, secret)) {
1639-
throw new SecurityException("invalid webhook signature");
1642+
throw new InvalidWebhookException(InvalidWebhookException.SIGNATURE_MISMATCH);
16401643
}
16411644
return parseEvent(payload);
16421645
}
@@ -1650,17 +1653,18 @@ private static ObjectMapper buildWebhookObjectMapper() {
16501653
* @param signature value of the {@code X-Signature} header
16511654
* @param secret the app's API secret
16521655
* @return the parsed event
1653-
* @throws SecurityException when the signature does not match
1654-
* @throws IllegalStateException when the gzip envelope is malformed or the payload is not JSON
1656+
* @throws InvalidWebhookException when the signature does not match, the gzip envelope is
1657+
* malformed, or the payload is not JSON
16551658
*/
16561659
public static @NotNull Event verifyAndParseWebhook(
1657-
@NotNull byte[] body, @NotNull String signature, @NotNull String secret) {
1660+
@NotNull byte[] body, @NotNull String signature, @NotNull String secret)
1661+
throws InvalidWebhookException {
16581662
return verifyAndParseInternal(gunzipPayload(body), signature, secret);
16591663
}
16601664

16611665
/** Singleton-secret overload: uses the API secret of the configured {@link Client} singleton. */
16621666
public static @NotNull Event verifyAndParseWebhook(
1663-
@NotNull byte[] body, @NotNull String signature) {
1667+
@NotNull byte[] body, @NotNull String signature) throws InvalidWebhookException {
16641668
return verifyAndParseWebhook(body, signature, Client.getInstance().getApiSecret());
16651669
}
16661670

@@ -1669,13 +1673,14 @@ private static ObjectMapper buildWebhookObjectMapper() {
16691673
* from the {@code X-Signature} message attribute, and return the parsed {@link Event}.
16701674
*/
16711675
public static @NotNull Event verifyAndParseSqs(
1672-
@NotNull String messageBody, @NotNull String signature, @NotNull String secret) {
1676+
@NotNull String messageBody, @NotNull String signature, @NotNull String secret)
1677+
throws InvalidWebhookException {
16731678
return verifyAndParseInternal(decodeSqsPayload(messageBody), signature, secret);
16741679
}
16751680

16761681
/** Singleton-secret overload of {@link #verifyAndParseSqs(String, String, String)}. */
16771682
public static @NotNull Event verifyAndParseSqs(
1678-
@NotNull String messageBody, @NotNull String signature) {
1683+
@NotNull String messageBody, @NotNull String signature) throws InvalidWebhookException {
16791684
return verifyAndParseSqs(messageBody, signature, Client.getInstance().getApiSecret());
16801685
}
16811686

@@ -1684,13 +1689,14 @@ private static ObjectMapper buildWebhookObjectMapper() {
16841689
* signature} from the {@code X-Signature} message attribute, and return the parsed {@link Event}.
16851690
*/
16861691
public static @NotNull Event verifyAndParseSns(
1687-
@NotNull String message, @NotNull String signature, @NotNull String secret) {
1692+
@NotNull String message, @NotNull String signature, @NotNull String secret)
1693+
throws InvalidWebhookException {
16881694
return verifyAndParseInternal(decodeSnsPayload(message), signature, secret);
16891695
}
16901696

16911697
/** Singleton-secret overload of {@link #verifyAndParseSns(String, String, String)}. */
1692-
public static @NotNull Event verifyAndParseSns(
1693-
@NotNull String message, @NotNull String signature) {
1698+
public static @NotNull Event verifyAndParseSns(@NotNull String message, @NotNull String signature)
1699+
throws InvalidWebhookException {
16941700
return verifyAndParseSns(message, signature, Client.getInstance().getApiSecret());
16951701
}
16961702

src/main/java/io/getstream/chat/java/services/framework/Client.java

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package io.getstream.chat.java.services.framework;
22

3+
import io.getstream.chat.java.exceptions.InvalidWebhookException;
34
import io.getstream.chat.java.models.App;
45
import io.getstream.chat.java.models.Event;
56
import java.time.Duration;
@@ -31,7 +32,8 @@ public interface Client {
3132
* @param signature value of the {@code X-Signature} header
3233
* @return parsed {@link Event}
3334
*/
34-
default @NotNull Event verifyAndParseWebhook(@NotNull byte[] body, @NotNull String signature) {
35+
default @NotNull Event verifyAndParseWebhook(@NotNull byte[] body, @NotNull String signature)
36+
throws InvalidWebhookException {
3537
return App.verifyAndParseWebhook(body, signature, getApiSecret());
3638
}
3739

@@ -44,7 +46,8 @@ public interface Client {
4446
* @param signature value of the {@code X-Signature} message attribute
4547
* @return parsed {@link Event}
4648
*/
47-
default @NotNull Event verifyAndParseSqs(@NotNull String messageBody, @NotNull String signature) {
49+
default @NotNull Event verifyAndParseSqs(@NotNull String messageBody, @NotNull String signature)
50+
throws InvalidWebhookException {
4851
return App.verifyAndParseSqs(messageBody, signature, getApiSecret());
4952
}
5053

@@ -57,7 +60,8 @@ public interface Client {
5760
* @param signature value of the {@code X-Signature} message attribute
5861
* @return parsed {@link Event}
5962
*/
60-
default @NotNull Event verifyAndParseSns(@NotNull String message, @NotNull String signature) {
63+
default @NotNull Event verifyAndParseSns(@NotNull String message, @NotNull String signature)
64+
throws InvalidWebhookException {
6165
return App.verifyAndParseSns(message, signature, getApiSecret());
6266
}
6367

0 commit comments

Comments
 (0)