Skip to content

Commit d69cbdf

Browse files
feat: standard exceptions (#55)
* feat: add support for standard exception propagation * chore: improve error propagation * refactor: adjust visibility
1 parent 38cd0fc commit d69cbdf

File tree

6 files changed

+213
-6
lines changed

6 files changed

+213
-6
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
package org.hypertrace.core.grpcutils.context;
2+
3+
import io.grpc.Metadata;
4+
import io.grpc.Metadata.Key;
5+
import java.util.Optional;
6+
import javax.annotation.Nonnull;
7+
import javax.annotation.Nullable;
8+
import lombok.AccessLevel;
9+
import lombok.AllArgsConstructor;
10+
import lombok.Value;
11+
12+
@Value
13+
@AllArgsConstructor(access = AccessLevel.PACKAGE)
14+
public class ContextualExceptionDetails {
15+
static final Key<String> EXTERNAL_MESSAGE_KEY =
16+
Key.of("external-message", Metadata.ASCII_STRING_MARSHALLER);
17+
@Nonnull RequestContext requestContext;
18+
@Nullable String externalMessage;
19+
20+
public Optional<String> getExternalMessage() {
21+
return Optional.ofNullable(this.externalMessage);
22+
}
23+
24+
ContextualExceptionDetails(@Nonnull RequestContext requestContext) {
25+
this(requestContext, null);
26+
}
27+
28+
Metadata toMetadata() {
29+
Metadata metadata = new Metadata();
30+
this.getExternalMessage().ifPresent(value -> metadata.put(EXTERNAL_MESSAGE_KEY, value));
31+
metadata.merge(this.getRequestContext().buildTrailers());
32+
return metadata;
33+
}
34+
35+
ContextualExceptionDetails withExternalMessage(@Nullable String externalMessage) {
36+
return new ContextualExceptionDetails(this.getRequestContext(), externalMessage);
37+
}
38+
39+
public static Optional<ContextualExceptionDetails> fromMetadata(Metadata metadata) {
40+
RequestContext requestContext = RequestContext.fromMetadata(metadata);
41+
if (requestContext.getRequestId().isEmpty()) {
42+
return Optional.empty();
43+
}
44+
return Optional.of(
45+
new ContextualExceptionDetails(requestContext, metadata.get(EXTERNAL_MESSAGE_KEY)));
46+
}
47+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
package org.hypertrace.core.grpcutils.context;
2+
3+
import io.grpc.Status;
4+
import io.grpc.StatusException;
5+
import io.grpc.StatusRuntimeException;
6+
import javax.annotation.Nonnull;
7+
import lombok.AccessLevel;
8+
import lombok.AllArgsConstructor;
9+
import lombok.EqualsAndHashCode;
10+
import lombok.Getter;
11+
import lombok.ToString;
12+
13+
@Getter
14+
@EqualsAndHashCode
15+
@ToString
16+
@AllArgsConstructor(access = AccessLevel.PRIVATE)
17+
public class ContextualStatusExceptionBuilder {
18+
19+
private final Status status;
20+
ContextualExceptionDetails details;
21+
22+
public static ContextualStatusExceptionBuilder from(Status status, RequestContext context) {
23+
return new ContextualStatusExceptionBuilder(status, new ContextualExceptionDetails(context));
24+
}
25+
26+
public ContextualStatusExceptionBuilder useStatusDescriptionAsExternalMessage() {
27+
this.details = this.details.withExternalMessage(this.status.getDescription());
28+
return this;
29+
}
30+
31+
public ContextualStatusExceptionBuilder withExternalMessage(@Nonnull String externalMessage) {
32+
this.details = this.details.withExternalMessage(externalMessage);
33+
return this;
34+
}
35+
36+
public StatusRuntimeException buildRuntimeException() {
37+
return status.asRuntimeException(this.details.toMetadata());
38+
}
39+
40+
public StatusException buildCheckedException() {
41+
return status.asException(this.details.toMetadata());
42+
}
43+
}

grpc-context-utils/src/main/java/org/hypertrace/core/grpcutils/context/RequestContext.java

+8-3
Original file line numberDiff line numberDiff line change
@@ -21,14 +21,17 @@
2121
import java.util.concurrent.Callable;
2222
import java.util.stream.Collectors;
2323
import javax.annotation.Nonnull;
24+
import lombok.EqualsAndHashCode;
2425
import lombok.Value;
2526

2627
/**
2728
* Context of the GRPC request that should be carried and can made available to the services so that
2829
* the service can use them. We use this to propagate headers across services.
2930
*/
31+
@EqualsAndHashCode
3032
public class RequestContext {
3133
public static final Context.Key<RequestContext> CURRENT = Context.key("request_context");
34+
private static final JwtParser JWT_PARSER = new JwtParser();
3235

3336
public static RequestContext forTenantId(String tenantId) {
3437
return new RequestContext()
@@ -68,7 +71,6 @@ public static RequestContext fromMetadata(Metadata metadata) {
6871

6972
private final ListMultimap<String, RequestContextHeader> headers =
7073
MultimapBuilder.linkedHashKeys().linkedListValues().build();
71-
private final JwtParser jwtParser = new JwtParser();
7274

7375
/** Reads tenant id from this RequestContext based on the tenant id http header and returns it. */
7476
public Optional<String> getTenantId() {
@@ -108,7 +110,7 @@ public Optional<String> getRequestId() {
108110

109111
private Optional<Jwt> getJwt() {
110112
return this.getHeaderValue(RequestContextConstants.AUTHORIZATION_HEADER)
111-
.flatMap(jwtParser::fromAuthHeader);
113+
.flatMap(JWT_PARSER::fromAuthHeader);
112114
}
113115

114116
/**
@@ -277,13 +279,16 @@ public <T> ContextualKey<T> buildInternalContextualKey(T data) {
277279
/** Converts the request context into metadata to be used as trailers */
278280
public Metadata buildTrailers() {
279281
Metadata trailers = new Metadata();
280-
// For now, the only context item to use as a trailer is the request id
282+
// Propagate the tenant id and request id back
281283
this.getRequestId()
282284
.ifPresent(
283285
requestId ->
284286
trailers.put(
285287
Key.of(RequestContextConstants.REQUEST_ID_HEADER_KEY, ASCII_STRING_MARSHALLER),
286288
requestId));
289+
this.getTenantId()
290+
.ifPresent(
291+
tenantId -> trailers.put(RequestContextConstants.TENANT_ID_METADATA_KEY, tenantId));
287292
return trailers;
288293
}
289294

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
package org.hypertrace.core.grpcutils.context;
2+
3+
import static io.grpc.Metadata.ASCII_STRING_MARSHALLER;
4+
import static org.hypertrace.core.grpcutils.context.ContextualExceptionDetails.EXTERNAL_MESSAGE_KEY;
5+
import static org.hypertrace.core.grpcutils.context.RequestContextConstants.REQUEST_ID_HEADER_KEY;
6+
import static org.hypertrace.core.grpcutils.context.RequestContextConstants.TENANT_ID_HEADER_KEY;
7+
import static org.junit.jupiter.api.Assertions.assertEquals;
8+
9+
import io.grpc.Metadata;
10+
import io.grpc.Metadata.Key;
11+
import io.grpc.Status;
12+
import io.grpc.StatusException;
13+
import java.util.Map;
14+
import java.util.Optional;
15+
import java.util.Set;
16+
import org.junit.jupiter.api.Test;
17+
18+
class ContextualExceptionDetailsTest {
19+
static final String TEST_REQUEST_ID = "example-request-id";
20+
static final String TEST_TENANT = "example-tenant";
21+
static final RequestContext TEST_CONTEXT =
22+
new RequestContext()
23+
.put(REQUEST_ID_HEADER_KEY, TEST_REQUEST_ID)
24+
.put(TENANT_ID_HEADER_KEY, TEST_TENANT);
25+
26+
@Test
27+
void convertsExceptionWithoutMessageToMetadata() {
28+
StatusException exception =
29+
ContextualStatusExceptionBuilder.from(
30+
Status.INVALID_ARGUMENT.withDescription("Some internal description"), TEST_CONTEXT)
31+
.buildCheckedException();
32+
33+
assertEquals(
34+
Set.of(REQUEST_ID_HEADER_KEY, TENANT_ID_HEADER_KEY), exception.getTrailers().keys());
35+
assertEquals(TEST_CONTEXT, RequestContext.fromMetadata(exception.getTrailers()));
36+
}
37+
38+
@Test
39+
void convertsExceptionWithDescriptionAsExternalMessageToMetadata() {
40+
StatusException exception =
41+
ContextualStatusExceptionBuilder.from(
42+
Status.INVALID_ARGUMENT.withDescription("Some description"), TEST_CONTEXT)
43+
.useStatusDescriptionAsExternalMessage()
44+
.buildCheckedException();
45+
46+
assertEquals(
47+
Set.of(REQUEST_ID_HEADER_KEY, TENANT_ID_HEADER_KEY, EXTERNAL_MESSAGE_KEY.originalName()),
48+
exception.getTrailers().keys());
49+
assertEquals(TEST_CONTEXT, RequestContext.fromMetadata(exception.getTrailers()));
50+
assertEquals("Some description", exception.getTrailers().get(EXTERNAL_MESSAGE_KEY));
51+
}
52+
53+
@Test
54+
void convertsExceptionWithCustomExternalMessageToMetadata() {
55+
StatusException exception =
56+
ContextualStatusExceptionBuilder.from(
57+
Status.INVALID_ARGUMENT.withDescription("Some internal description"), TEST_CONTEXT)
58+
.withExternalMessage("custom external description")
59+
.buildCheckedException();
60+
61+
assertEquals(
62+
Set.of(REQUEST_ID_HEADER_KEY, TENANT_ID_HEADER_KEY, EXTERNAL_MESSAGE_KEY.originalName()),
63+
exception.getTrailers().keys());
64+
assertEquals(TEST_CONTEXT, RequestContext.fromMetadata(exception.getTrailers()));
65+
assertEquals("custom external description", exception.getTrailers().get(EXTERNAL_MESSAGE_KEY));
66+
}
67+
68+
@Test
69+
void emptyIfUnableToParseContext() {
70+
assertEquals(
71+
Optional.empty(),
72+
ContextualExceptionDetails.fromMetadata(
73+
metadataFromMap(Map.of("random-key", "random-value"))));
74+
}
75+
76+
@Test
77+
void parsesMetadataWithoutMessage() {
78+
assertEquals(
79+
Optional.of(new ContextualExceptionDetails(TEST_CONTEXT)),
80+
ContextualExceptionDetails.fromMetadata(
81+
metadataFromMap(
82+
Map.of(
83+
REQUEST_ID_HEADER_KEY, TEST_REQUEST_ID, TENANT_ID_HEADER_KEY, TEST_TENANT))));
84+
}
85+
86+
@Test
87+
void parsesMetadataWithMessage() {
88+
assertEquals(
89+
Optional.of(new ContextualExceptionDetails(TEST_CONTEXT, "test message")),
90+
ContextualExceptionDetails.fromMetadata(
91+
metadataFromMap(
92+
Map.of(
93+
REQUEST_ID_HEADER_KEY,
94+
TEST_REQUEST_ID,
95+
TENANT_ID_HEADER_KEY,
96+
TEST_TENANT,
97+
EXTERNAL_MESSAGE_KEY.originalName(),
98+
"test message"))));
99+
}
100+
101+
Metadata metadataFromMap(Map<String, String> metadataMap) {
102+
Metadata metadata = new Metadata();
103+
metadataMap.forEach((key, value) -> metadata.put(Key.of(key, ASCII_STRING_MARSHALLER), value));
104+
return metadata;
105+
}
106+
}

grpc-context-utils/src/test/java/org/hypertrace/core/grpcutils/context/RequestContextTest.java

+5-3
Original file line numberDiff line numberDiff line change
@@ -161,16 +161,18 @@ public void testMetadataKeys() {
161161

162162
@Test
163163
void buildsTrailers() {
164-
RequestContext requestContext = RequestContext.forTenantId("test");
164+
RequestContext requestContext =
165+
RequestContext.forTenantId("test").put("other-header", "other-value");
165166

166167
// Try building trailers and then request context from them.
167168
RequestContext requestContextFromBuiltTrailers =
168169
RequestContext.fromMetadata(requestContext.buildTrailers());
169170

170-
// Should not be equal because tenant id is not a trailer so should be lost
171+
// Should not be equal because other header is not a trailer so should be lost
171172
assertNotEquals(requestContext, requestContextFromBuiltTrailers);
172-
// Request IDs should however be equal
173+
// Request ID and tenant ID should however be equal
173174
assertEquals(requestContext.getRequestId(), requestContextFromBuiltTrailers.getRequestId());
175+
assertEquals(requestContext.getTenantId(), requestContextFromBuiltTrailers.getTenantId());
174176
}
175177

176178
@Test

grpc-server-utils/src/main/java/org/hypertrace/core/grpcutils/server/ThrowableResponseInterceptor.java

+4
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import io.grpc.ServerCallHandler;
77
import io.grpc.ServerInterceptor;
88
import io.grpc.Status;
9+
import org.hypertrace.core.grpcutils.context.RequestContext;
910

1011
/** Server Interceptor that decorates the error response status before closing the call */
1112
public class ThrowableResponseInterceptor implements ServerInterceptor {
@@ -29,6 +30,9 @@ public void close(Status status, Metadata trailers) {
2930
.withDescription(status.getCause().getMessage())
3031
.withCause(status.getCause());
3132
}
33+
if (!status.isOk() && trailers.keys().isEmpty()) {
34+
super.close(status, RequestContext.fromMetadata(headers).buildTrailers());
35+
}
3236
super.close(status, trailers);
3337
}
3438
};

0 commit comments

Comments
 (0)