Skip to content

Commit c6d7fb0

Browse files
carterkozakbulldozer-bot[bot]
authored andcommitted
fix #347: ServiceException retains causal errorInstanceId (#348)
fix #347: ServiceException retains causal `errorInstanceId`
1 parent 76a0dd2 commit c6d7fb0

File tree

3 files changed

+69
-1
lines changed

3 files changed

+69
-1
lines changed
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
type: improvement
2+
improvement:
3+
description: ServiceException retains the `errorInstanceId` from its cause, allowing RemoteExceptions
4+
to be caught and used as the cause for as more specific ServiceException without sacrificing observability.
5+
In most cases a single error identifier query is sufficient to discover the root cause of a failure
6+
instead of individually requesting each segment back to the root.
7+
links:
8+
- https://github.com/palantir/conjure-java-runtime-api/pull/348

errors/src/main/java/com/palantir/conjure/java/api/errors/ServiceException.java

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,9 @@
2020
import com.palantir.logsafe.SafeLoggable;
2121
import java.util.ArrayList;
2222
import java.util.Collections;
23+
import java.util.IdentityHashMap;
2324
import java.util.List;
25+
import java.util.Set;
2426
import java.util.UUID;
2527
import javax.annotation.Nullable;
2628

@@ -30,7 +32,7 @@ public final class ServiceException extends RuntimeException implements SafeLogg
3032
private final ErrorType errorType;
3133
private final List<Arg<?>> args; // unmodifiable
3234

33-
private final String errorInstanceId = UUID.randomUUID().toString();
35+
private final String errorInstanceId;
3436
private final String unsafeMessage;
3537
private final String noArgsMessage;
3638

@@ -47,6 +49,7 @@ public ServiceException(ErrorType errorType, @Nullable Throwable cause, Arg<?>..
4749
// TODO(rfink): Memoize formatting?
4850
super(cause);
4951

52+
this.errorInstanceId = generateErrorInstanceId(cause);
5053
this.errorType = errorType;
5154
// Note that instantiators cannot mutate List<> args since it comes through copyToList in all code paths.
5255
this.args = copyToUnmodifiableList(args);
@@ -122,4 +125,29 @@ private static String renderUnsafeMessage(ErrorType errorType, Arg<?>... args) {
122125
private static String renderNoArgsMessage(ErrorType errorType) {
123126
return String.format("ServiceException: %s (%s)", errorType.code(), errorType.name());
124127
}
128+
129+
/**
130+
* Finds the errorInstanceId of the most recent cause if present, otherwise generates a new random identifier.
131+
* Note that this only searches {@link Throwable#getCause() causal exceptions}, not
132+
* {@link Throwable#getSuppressed() suppressed causes}.
133+
*/
134+
private static String generateErrorInstanceId(@Nullable Throwable cause) {
135+
return generateErrorInstanceId(cause, Collections.newSetFromMap(new IdentityHashMap<>()));
136+
}
137+
138+
private static String generateErrorInstanceId(
139+
@Nullable Throwable cause,
140+
// Guard against cause cycles, see Throwable.printStackTrace(PrintStreamOrWriter)
141+
Set<Throwable> dejaVu) {
142+
if (cause == null || !dejaVu.add(cause)) {
143+
return UUID.randomUUID().toString();
144+
}
145+
if (cause instanceof ServiceException) {
146+
return ((ServiceException) cause).getErrorInstanceId();
147+
}
148+
if (cause instanceof RemoteException) {
149+
return ((RemoteException) cause).getError().errorInstanceId();
150+
}
151+
return generateErrorInstanceId(cause.getCause(), dejaVu);
152+
}
125153
}

errors/src/test/java/com/palantir/conjure/java/api/errors/ServiceExceptionTest.java

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
import com.palantir.logsafe.Arg;
2222
import com.palantir.logsafe.SafeArg;
2323
import com.palantir.logsafe.UnsafeArg;
24+
import com.palantir.logsafe.exceptions.SafeRuntimeException;
2425
import java.util.UUID;
2526
import org.junit.jupiter.api.Test;
2627

@@ -81,4 +82,35 @@ public void testErrorIdsAreUnique() {
8182

8283
assertThat(errorId1).isNotEqualTo(errorId2);
8384
}
85+
86+
@Test
87+
public void testErrorIdsAreInheritedFromServiceExceptions() {
88+
ServiceException rootCause = new ServiceException(ERROR);
89+
SafeRuntimeException intermediate = new SafeRuntimeException("Handled an exception", rootCause);
90+
ServiceException parent = new ServiceException(ERROR, intermediate);
91+
assertThat(parent.getErrorInstanceId()).isEqualTo(rootCause.getErrorInstanceId());
92+
}
93+
94+
@Test
95+
public void testErrorIdsAreInheritedFromRemoteExceptions() {
96+
RemoteException rootCause = new RemoteException(new SerializableError.Builder()
97+
.errorCode("errorCode")
98+
.errorName("errorName")
99+
.build(), 500);
100+
SafeRuntimeException intermediate = new SafeRuntimeException("Handled an exception", rootCause);
101+
ServiceException parent = new ServiceException(ERROR, intermediate);
102+
assertThat(parent.getErrorInstanceId()).isEqualTo(rootCause.getError().errorInstanceId());
103+
}
104+
105+
@Test
106+
public void testCircularCause() {
107+
RuntimeException first = new RuntimeException();
108+
RuntimeException second = new RuntimeException(first);
109+
// Yes, you can do this. In practice it's often more subtle when libraries attempt to piece together
110+
// more helpful exception chains and encounter unexpected edge cases.
111+
first.initCause(second);
112+
// invoke getErrorInstanceId to ensure this is tested even if future developers
113+
// optimize generation to occur lazily.
114+
assertThat(new ServiceException(ERROR, second).getErrorInstanceId()).isNotNull();
115+
}
84116
}

0 commit comments

Comments
 (0)