Skip to content
Open
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
10 changes: 7 additions & 3 deletions client/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,8 @@ compileTestJava {
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
options.fork = true
options.forkOptions.executable = "${PATH_TO_TEST_JAVA_RUNTIME}/bin/javac"
def javacName = org.gradle.internal.os.OperatingSystem.current().isWindows() ? 'javac.exe' : 'javac'
options.forkOptions.executable = "${PATH_TO_TEST_JAVA_RUNTIME}/bin/${javacName}"
}

task downloadProtoFiles {
Expand Down Expand Up @@ -90,7 +91,9 @@ protobuf {
}
generateProtoTasks {
all()*.plugins { grpc {} }
all()*.dependsOn downloadProtoFiles
if (project.gradle.startParameter.taskNames.any { it.contains('downloadProtoFiles') }) {
all()*.dependsOn downloadProtoFiles
}
}
}

Expand All @@ -107,7 +110,8 @@ sourceSets {
}

tasks.withType(Test) {
executable = new File("${PATH_TO_TEST_JAVA_RUNTIME}", 'bin/java')
def javaName = org.gradle.internal.os.OperatingSystem.current().isWindows() ? 'java.exe' : 'java'
executable = new File("${PATH_TO_TEST_JAVA_RUNTIME}", "bin/${javaName}")
}

test {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ public final class DurableTaskGrpcWorker implements AutoCloseable {
private final DataConverter dataConverter;
private final Duration maximumTimerInterval;
private final DurableTaskGrpcWorkerVersioningOptions versioningOptions;
private final ExceptionPropertiesProvider exceptionPropertiesProvider;

private final TaskHubSidecarServiceBlockingStub sidecarClient;

Expand Down Expand Up @@ -65,6 +66,7 @@ public final class DurableTaskGrpcWorker implements AutoCloseable {
this.dataConverter = builder.dataConverter != null ? builder.dataConverter : new JacksonDataConverter();
this.maximumTimerInterval = builder.maximumTimerInterval != null ? builder.maximumTimerInterval : DEFAULT_MAXIMUM_TIMER_INTERVAL;
this.versioningOptions = builder.versioningOptions;
this.exceptionPropertiesProvider = builder.exceptionPropertiesProvider;
}

/**
Expand Down Expand Up @@ -118,7 +120,8 @@ public void startAndBlock() {
this.dataConverter,
this.maximumTimerInterval,
logger,
this.versioningOptions);
this.versioningOptions,
this.exceptionPropertiesProvider);
TaskActivityExecutor taskActivityExecutor = new TaskActivityExecutor(
this.activityFactories,
this.dataConverter,
Expand Down Expand Up @@ -228,11 +231,9 @@ public void startAndBlock() {
activityRequest.getInput().getValue(),
activityRequest.getTaskId());
} catch (Throwable e) {
failureDetails = TaskFailureDetails.newBuilder()
.setErrorType(e.getClass().getName())
.setErrorMessage(e.getMessage())
.setStackTrace(StringValue.of(FailureDetails.getFullStackTrace(e)))
.build();
Exception ex = e instanceof Exception ? (Exception) e : new RuntimeException(e);
failureDetails = FailureDetails.fromException(
ex, this.exceptionPropertiesProvider).toProto();
}

ActivityResponse.Builder responseBuilder = ActivityResponse.newBuilder()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ public final class DurableTaskGrpcWorkerBuilder {
DataConverter dataConverter;
Duration maximumTimerInterval;
DurableTaskGrpcWorkerVersioningOptions versioningOptions;
ExceptionPropertiesProvider exceptionPropertiesProvider;

/**
* Adds an orchestration factory to be used by the constructed {@link DurableTaskGrpcWorker}.
Expand Down Expand Up @@ -125,6 +126,21 @@ public DurableTaskGrpcWorkerBuilder useVersioning(DurableTaskGrpcWorkerVersionin
return this;
}

/**
* Sets the {@link ExceptionPropertiesProvider} to use for extracting custom properties from exceptions.
* <p>
* When set, the provider is invoked whenever an activity or orchestration fails with an exception. The returned
* properties are included in the {@link FailureDetails} and can be retrieved via
* {@link FailureDetails#getProperties()}.
*
* @param provider the exception properties provider
* @return this builder object
*/
public DurableTaskGrpcWorkerBuilder exceptionPropertiesProvider(ExceptionPropertiesProvider provider) {
this.exceptionPropertiesProvider = provider;
return this;
}

/**
* Initializes a new {@link DurableTaskGrpcWorker} object with the settings specified in the current builder object.
* @return a new {@link DurableTaskGrpcWorker} object
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.
package com.microsoft.durabletask;

import javax.annotation.Nullable;
import java.util.Map;

/**
* Provider interface for extracting custom properties from exceptions.
* <p>
* Implementations of this interface can be registered with a {@link DurableTaskGrpcWorkerBuilder} to include
* custom exception properties in {@link FailureDetails} when activities or orchestrations fail.
* These properties are then available via {@link FailureDetails#getProperties()}.
* <p>
* Example usage:
* <pre>{@code
* DurableTaskGrpcWorker worker = new DurableTaskGrpcWorkerBuilder()
* .exceptionPropertiesProvider(exception -> {
* if (exception instanceof MyCustomException) {
* MyCustomException custom = (MyCustomException) exception;
* Map<String, Object> props = new HashMap<>();
* props.put("errorCode", custom.getErrorCode());
* props.put("retryable", custom.isRetryable());
* return props;
* }
* return null;
* })
* .addOrchestration(...)
* .build();
* }</pre>
*/
@FunctionalInterface
public interface ExceptionPropertiesProvider {

/**
* Extracts custom properties from the given exception.
* <p>
* Return {@code null} or an empty map if no custom properties should be included for this exception.
*
* @param exception the exception to extract properties from
* @return a map of property names to values, or {@code null}
*/
@Nullable
Map<String, Object> getExceptionProperties(Exception exception);
}
177 changes: 172 additions & 5 deletions client/src/main/java/com/microsoft/durabletask/FailureDetails.java
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,16 @@
// Licensed under the MIT License.
package com.microsoft.durabletask;

import com.google.protobuf.NullValue;
import com.google.protobuf.StringValue;
import com.google.protobuf.Value;
import com.microsoft.durabletask.implementation.protobuf.OrchestratorService.TaskFailureDetails;

import javax.annotation.Nonnull;
import javax.annotation.Nullable;
import java.util.Collections;
import java.util.HashMap;
import java.util.Map;

/**
* Class that represents the details of a task failure.
Expand All @@ -20,29 +25,76 @@ public final class FailureDetails {
private final String errorMessage;
private final String stackTrace;
private final boolean isNonRetriable;
private final FailureDetails innerFailure;
private final Map<String, Object> properties;

FailureDetails(
String errorType,
@Nullable String errorMessage,
@Nullable String errorDetails,
boolean isNonRetriable) {
this(errorType, errorMessage, errorDetails, isNonRetriable, null, null);
}

FailureDetails(
String errorType,
@Nullable String errorMessage,
@Nullable String errorDetails,
boolean isNonRetriable,
@Nullable FailureDetails innerFailure,
@Nullable Map<String, Object> properties) {
this.errorType = errorType;
this.stackTrace = errorDetails;

// Error message can be null for things like NullPointerException but the gRPC contract doesn't allow null
this.errorMessage = errorMessage != null ? errorMessage : "";
this.isNonRetriable = isNonRetriable;
this.innerFailure = innerFailure;
this.properties = properties != null ? Collections.unmodifiableMap(new HashMap<>(properties)) : null;
}

FailureDetails(Exception exception) {
this(exception.getClass().getName(), exception.getMessage(), getFullStackTrace(exception), false);
this(exception.getClass().getName(),
exception.getMessage(),
getFullStackTrace(exception),
false,
exception.getCause() != null ? fromExceptionRecursive(exception.getCause(), null) : null,
null);
}

/**
* Creates a {@code FailureDetails} from an exception, optionally using the provided
* {@link ExceptionPropertiesProvider} to extract custom properties.
*
* @param exception the exception that caused the failure
* @param provider the provider for extracting custom properties, or {@code null}
* @return a new {@code FailureDetails} instance
*/
static FailureDetails fromException(Exception exception, @Nullable ExceptionPropertiesProvider provider) {
Map<String, Object> properties = null;
if (provider != null) {
try {
properties = provider.getExceptionProperties(exception);
} catch (Exception ignored) {
// Don't let provider errors mask the original failure
}
}
return new FailureDetails(
exception.getClass().getName(),
exception.getMessage(),
getFullStackTrace(exception),
false,
exception.getCause() != null ? fromExceptionRecursive(exception.getCause(), provider) : null,
properties);
}

FailureDetails(TaskFailureDetails proto) {
this(proto.getErrorType(),
proto.getErrorMessage(),
proto.getStackTrace().getValue(),
proto.getIsNonRetriable());
proto.getIsNonRetriable(),
proto.hasInnerFailure() ? new FailureDetails(proto.getInnerFailure()) : null,
convertProtoProperties(proto.getPropertiesMap()));
}

/**
Expand Down Expand Up @@ -86,6 +138,28 @@ public boolean isNonRetriable() {
return this.isNonRetriable;
}

/**
* Gets the inner failure that caused this failure, or {@code null} if there is no inner cause.
*
* @return the inner {@code FailureDetails} or {@code null}
*/
@Nullable
public FailureDetails getInnerFailure() {
return this.innerFailure;
}

/**
* Gets additional properties associated with the exception, or {@code null} if no properties are available.
* <p>
* The returned map is unmodifiable.
*
* @return an unmodifiable map of property names to values, or {@code null}
*/
@Nullable
public Map<String, Object> getProperties() {
return this.properties;
}

/**
* Returns {@code true} if the task failure was provided by the specified exception type, otherwise {@code false}.
* <p>
Expand All @@ -112,6 +186,11 @@ public boolean isCausedBy(Class<? extends Exception> exceptionClass) {
}
}

@Override
public String toString() {
return this.errorType + ": " + this.errorMessage;
}

static String getFullStackTrace(Throwable e) {
StackTraceElement[] elements = e.getStackTrace();

Expand All @@ -124,10 +203,98 @@ static String getFullStackTrace(Throwable e) {
}

TaskFailureDetails toProto() {
return TaskFailureDetails.newBuilder()
TaskFailureDetails.Builder builder = TaskFailureDetails.newBuilder()
.setErrorType(this.getErrorType())
.setErrorMessage(this.getErrorMessage())
.setStackTrace(StringValue.of(this.getStackTrace() != null ? this.getStackTrace() : ""))
.build();
.setIsNonRetriable(this.isNonRetriable);

if (this.innerFailure != null) {
builder.setInnerFailure(this.innerFailure.toProto());
}

if (this.properties != null) {
builder.putAllProperties(convertToProtoProperties(this.properties));
}

return builder.build();
}

@Nullable
private static FailureDetails fromExceptionRecursive(
@Nullable Throwable exception,
@Nullable ExceptionPropertiesProvider provider) {
if (exception == null) {
return null;
}
Map<String, Object> properties = null;
if (provider != null && exception instanceof Exception) {
try {
properties = provider.getExceptionProperties((Exception) exception);
} catch (Exception ignored) {
// Don't let provider errors mask the original failure
}
}
return new FailureDetails(
exception.getClass().getName(),
exception.getMessage(),
getFullStackTrace(exception),
false,
exception.getCause() != null ? fromExceptionRecursive(exception.getCause(), provider) : null,
properties);
}

@Nullable
private static Map<String, Object> convertProtoProperties(Map<String, Value> protoProperties) {
if (protoProperties == null || protoProperties.isEmpty()) {
return null;
}

Map<String, Object> result = new HashMap<>();
for (Map.Entry<String, Value> entry : protoProperties.entrySet()) {
result.put(entry.getKey(), convertProtoValue(entry.getValue()));
}
return result;
}

@Nullable
private static Object convertProtoValue(Value value) {
if (value == null) {
return null;
}
switch (value.getKindCase()) {
case NULL_VALUE:
return null;
case NUMBER_VALUE:
return value.getNumberValue();
case STRING_VALUE:
return value.getStringValue();
case BOOL_VALUE:
return value.getBoolValue();
default:
return value.toString();
}
Comment on lines +265 to +276
Copy link

Copilot AI Feb 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The convertProtoValue method doesn't handle LIST_VALUE and STRUCT_VALUE cases from google.protobuf.Value. While these cases may not be used currently, the default case silently converts them to string using toString(), which could lead to unexpected behavior if these types are used in the future. Consider explicitly handling these cases or documenting why they're not supported.

Copilot uses AI. Check for mistakes.
}

private static Map<String, Value> convertToProtoProperties(Map<String, Object> properties) {
Map<String, Value> result = new HashMap<>();
for (Map.Entry<String, Object> entry : properties.entrySet()) {
result.put(entry.getKey(), convertToProtoValue(entry.getValue()));
}
return result;
}

private static Value convertToProtoValue(@Nullable Object obj) {
if (obj == null) {
return Value.newBuilder().setNullValue(NullValue.NULL_VALUE).build();
} else if (obj instanceof Number) {
return Value.newBuilder().setNumberValue(((Number) obj).doubleValue()).build();
} else if (obj instanceof Boolean) {
return Value.newBuilder().setBoolValue((Boolean) obj).build();
} else if (obj instanceof String) {
return Value.newBuilder().setStringValue((String) obj).build();
} else {
return Value.newBuilder().setStringValue(obj.toString()).build();
}
}
}
}
Loading
Loading