Skip to content

Implement worker-side exception serialization for comprehensive error handling #223

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 4 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
115 changes: 108 additions & 7 deletions client/src/main/java/com/microsoft/durabletask/FailureDetails.java
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,30 @@ public final class FailureDetails {
this.isNonRetriable = isNonRetriable;
}

/**
* Creates a new failure details object from an exception.
* This constructor captures comprehensive exception information including:
* - Exception type
* - Exception message (including multiline messages)
* - Complete stack trace with inner/nested exceptions
* - Suppressed exceptions
*
* @param exception The exception to create failure details from
*/
FailureDetails(Exception exception) {
this(exception.getClass().getName(), exception.getMessage(), getFullStackTrace(exception), false);
// Use the most specific exception in the chain for error type
String errorType = exception.getClass().getName();
String errorMessage = exception.getMessage();

// Preserve null messages as empty string to match existing behavior
if (errorMessage == null) {
errorMessage = "";
}

this.errorType = errorType;
this.errorMessage = errorMessage;
this.stackTrace = getFullStackTrace(exception);
this.isNonRetriable = false;
}

FailureDetails(TaskFailureDetails proto) {
Expand Down Expand Up @@ -112,16 +134,95 @@ public boolean isCausedBy(Class<? extends Exception> exceptionClass) {
}
}

/**
* Generates a comprehensive stack trace string from a throwable, including inner exceptions, suppressed exceptions,
* and handling circular references. The format closely resembles Java's standard exception printing format.
*
* @param e The throwable to convert to a stack trace string
* @return A formatted stack trace string
*/
static String getFullStackTrace(Throwable e) {
StackTraceElement[] elements = e.getStackTrace();

// Plan for 256 characters per stack frame (which is likely on the high-end)
StringBuilder sb = new StringBuilder(elements.length * 256);
for (StackTraceElement element : elements) {
sb.append("\tat ").append(element.toString()).append(System.lineSeparator());
if (e == null) {
return "";
}

StringBuilder sb = new StringBuilder();

// Process the exception chain recursively
appendExceptionDetails(sb, e, null);

return sb.toString();
}

private static void appendExceptionDetails(StringBuilder sb, Throwable ex, StackTraceElement[] parentStackTrace) {
if (ex == null) {
return;
}

// Add the exception class name and message
sb.append(ex.getClass().getName());
String message = ex.getMessage();
if (message != null) {
sb.append(": ").append(message);
}
sb.append(System.lineSeparator());

// Add the stack trace elements
StackTraceElement[] currentStackTrace = ex.getStackTrace();
int framesInCommon = 0;
if (parentStackTrace != null) {
framesInCommon = countCommonFrames(currentStackTrace, parentStackTrace);
}

int framesToPrint = currentStackTrace.length - framesInCommon;
for (int i = 0; i < framesToPrint; i++) {
sb.append("\tat ").append(currentStackTrace[i].toString()).append(System.lineSeparator());
}

if (framesInCommon > 0) {
sb.append("\t... ").append(framesInCommon).append(" more").append(System.lineSeparator());
}

// Handle any suppressed exceptions
Throwable[] suppressed = ex.getSuppressed();
if (suppressed != null && suppressed.length > 0) {
for (Throwable s : suppressed) {
if (s != ex) { // Avoid circular references
sb.append("\tSuppressed: ");
appendExceptionDetails(sb, s, currentStackTrace);
} else {
sb.append("\tSuppressed: [CIRCULAR REFERENCE]").append(System.lineSeparator());
}
}
}

// Handle cause (inner exception)
Throwable cause = ex.getCause();
if (cause != null) {
if (cause != ex) { // Avoid direct circular references
sb.append("Caused by: ");
appendExceptionDetails(sb, cause, currentStackTrace);
} else {
sb.append("Caused by: [CIRCULAR REFERENCE]").append(System.lineSeparator());
}
}
}

/**
* Count frames in common between two stack traces, starting from the end.
* This helps produce more concise stack traces for chained exceptions.
*/
private static int countCommonFrames(StackTraceElement[] trace1, StackTraceElement[] trace2) {
int m = trace1.length - 1;
int n = trace2.length - 1;
int count = 0;
while (m >= 0 && n >= 0 && trace1[m].equals(trace2[n])) {
m--;
n--;
count++;
}
return count;
}

TaskFailureDetails toProto() {
return TaskFailureDetails.newBuilder()
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.

package com.microsoft.durabletask;

import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;

/**
* Tests for handling complex exception serialization scenarios.
*/
public class ComplexExceptionTest {

@Test
public void testDeepNestedExceptions() {
// Create a chain of 5 nested exceptions
Exception level5 = new IllegalArgumentException("Level 5 exception");
Exception level4 = new IllegalStateException("Level 4 exception", level5);
Exception level3 = new RuntimeException("Level 3 exception", level4);
Exception level2 = new Exception("Level 2 exception", level3);
Exception level1 = new Exception("Level 1 exception", level2);

FailureDetails details = new FailureDetails(level1);

assertEquals("java.lang.Exception", details.getErrorType());
assertEquals("Level 1 exception", details.getErrorMessage());

String stackTrace = details.getStackTrace();
assertNotNull(stackTrace);

// Verify all exception levels are present in the stack trace
assertTrue(stackTrace.contains("Level 1 exception"));
assertTrue(stackTrace.contains("Caused by: java.lang.Exception: Level 2 exception"));
assertTrue(stackTrace.contains("Caused by: java.lang.RuntimeException: Level 3 exception"));
assertTrue(stackTrace.contains("Caused by: java.lang.IllegalStateException: Level 4 exception"));
assertTrue(stackTrace.contains("Caused by: java.lang.IllegalArgumentException: Level 5 exception"));
}

@Test
public void testExceptionWithSuppressedExceptions() {
Exception mainException = new RuntimeException("Main exception");
Exception suppressed1 = new IllegalArgumentException("Suppressed exception 1");
Exception suppressed2 = new IllegalStateException("Suppressed exception 2");

mainException.addSuppressed(suppressed1);
mainException.addSuppressed(suppressed2);

FailureDetails details = new FailureDetails(mainException);

assertEquals("java.lang.RuntimeException", details.getErrorType());
assertEquals("Main exception", details.getErrorMessage());

String stackTrace = details.getStackTrace();
assertNotNull(stackTrace);

// Verify suppressed exceptions are in the stack trace
assertTrue(stackTrace.contains("Main exception"));
assertTrue(stackTrace.contains("Suppressed: java.lang.IllegalArgumentException: Suppressed exception 1"));
assertTrue(stackTrace.contains("Suppressed: java.lang.IllegalStateException: Suppressed exception 2"));
}

@Test
public void testNullMessageException() {
NullPointerException exception = new NullPointerException(); // NPE typically has null message

FailureDetails details = new FailureDetails(exception);

assertEquals("java.lang.NullPointerException", details.getErrorType());
assertEquals("", details.getErrorMessage()); // Should convert null to empty string
assertNotNull(details.getStackTrace());
}

@Test
public void testCircularExceptionReference() {
try {
// Create an exception with a circular reference (should be handled gracefully)
ExceptionWithCircularReference ex = new ExceptionWithCircularReference("Circular");
ex.setCircularCause();

FailureDetails details = new FailureDetails(ex);

assertEquals(ExceptionWithCircularReference.class.getName(), details.getErrorType());
assertEquals("Circular", details.getErrorMessage());
assertNotNull(details.getStackTrace());

// No infinite loop, test passes if we get here
} catch (StackOverflowError e) {
fail("StackOverflowError occurred with circular exception reference");
}
}

/**
* Exception class that can create a circular reference in the cause chain.
*/
private static class ExceptionWithCircularReference extends Exception {
public ExceptionWithCircularReference(String message) {
super(message);
}

public void setCircularCause() {
try {
// Use reflection to set the cause field directly to this exception
// to create a circular reference
java.lang.reflect.Field causeField = Throwable.class.getDeclaredField("cause");
causeField.setAccessible(true);
causeField.set(this, this);
} catch (Exception e) {
// Ignore any reflection errors, this is just for testing
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.

package com.microsoft.durabletask;

import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;

import java.util.concurrent.TimeoutException;

import static org.junit.jupiter.api.Assertions.*;

/**
* Integration tests for validating the serialization of exceptions in various scenarios.
*/
@Tag("integration")
@ExtendWith(TestRetryExtension.class)
public class ExceptionSerializationIntegrationTest extends IntegrationTestBase {

@RetryingTest
void testMultilineExceptionMessage() throws TimeoutException {
final String orchestratorName = "MultilineExceptionOrchestrator";
final String multilineErrorMessage = "Line 1\nLine 2\nLine 3";

DurableTaskGrpcWorker worker = this.createWorkerBuilder()
.addOrchestrator(orchestratorName, ctx -> {
throw new RuntimeException(multilineErrorMessage);
})
.buildAndStart();

DurableTaskClient client = new DurableTaskGrpcClientBuilder().build();
try (worker; client) {
String instanceId = client.scheduleNewOrchestrationInstance(orchestratorName, 0);
OrchestrationMetadata instance = client.waitForInstanceCompletion(instanceId, defaultTimeout, true);
assertNotNull(instance);
assertEquals(OrchestrationRuntimeStatus.FAILED, instance.getRuntimeStatus());

FailureDetails details = instance.getFailureDetails();
assertNotNull(details);
assertEquals("java.lang.RuntimeException", details.getErrorType());
assertEquals(multilineErrorMessage, details.getErrorMessage());
assertNotNull(details.getStackTrace());
}
}

@RetryingTest
void testNestedExceptions() throws TimeoutException {
final String orchestratorName = "NestedExceptionOrchestrator";
final String innerMessage = "Inner exception";
final String outerMessage = "Outer exception";

DurableTaskGrpcWorker worker = this.createWorkerBuilder()
.addOrchestrator(orchestratorName, ctx -> {
Exception innerException = new IllegalArgumentException(innerMessage);
throw new RuntimeException(outerMessage, innerException);
})
.buildAndStart();

DurableTaskClient client = new DurableTaskGrpcClientBuilder().build();
try (worker; client) {
String instanceId = client.scheduleNewOrchestrationInstance(orchestratorName, 0);
OrchestrationMetadata instance = client.waitForInstanceCompletion(instanceId, defaultTimeout, true);
assertNotNull(instance);
assertEquals(OrchestrationRuntimeStatus.FAILED, instance.getRuntimeStatus());

FailureDetails details = instance.getFailureDetails();
assertNotNull(details);
assertEquals("java.lang.RuntimeException", details.getErrorType());
assertEquals(outerMessage, details.getErrorMessage());
assertNotNull(details.getStackTrace());

// Verify both exceptions are in the stack trace
String stackTrace = details.getStackTrace();
assertTrue(stackTrace.contains(outerMessage), "Stack trace should contain outer exception message");
assertTrue(stackTrace.contains(innerMessage), "Stack trace should contain inner exception message");
assertTrue(stackTrace.contains("Caused by: java.lang.IllegalArgumentException"),
"Stack trace should include 'Caused by' section for inner exception");
}
}

@RetryingTest
void testCustomExceptionWithNonStandardToString() throws TimeoutException {
final String orchestratorName = "CustomExceptionOrchestrator";
final String customMessage = "Custom exception message";

DurableTaskGrpcWorker worker = this.createWorkerBuilder()
.addOrchestrator(orchestratorName, ctx -> {
throw new CustomException(customMessage);
})
.buildAndStart();

DurableTaskClient client = new DurableTaskGrpcClientBuilder().build();
try (worker; client) {
String instanceId = client.scheduleNewOrchestrationInstance(orchestratorName, 0);
OrchestrationMetadata instance = client.waitForInstanceCompletion(instanceId, defaultTimeout, true);
assertNotNull(instance);
assertEquals(OrchestrationRuntimeStatus.FAILED, instance.getRuntimeStatus());

FailureDetails details = instance.getFailureDetails();
assertNotNull(details);
String expectedType = CustomException.class.getName();
assertEquals(expectedType, details.getErrorType());
assertEquals(customMessage, details.getErrorMessage());
assertNotNull(details.getStackTrace());
}
}

/**
* Custom exception class with a non-standard toString implementation.
*/
private static class CustomException extends RuntimeException {
public CustomException(String message) {
super(message);
}

@Override
public String toString() {
return "CUSTOM_EXCEPTION_FORMAT: " + getMessage();
}
}
}
Loading
Loading