From f0d5d9c6d330cfd396cc67e151be209033041aaa Mon Sep 17 00:00:00 2001 From: Roey Berman Date: Wed, 19 Aug 2026 13:47:12 -0700 Subject: [PATCH 1/3] Add payload validation failure factory --- .../converter/PayloadValidationException.java | 23 +++++++++++ .../PayloadValidationExceptionTest.java | 38 +++++++++++++++++++ .../internal/nexus/PayloadSerializerTest.java | 10 ++--- ...utDeserializationErrorPropagationTest.java | 12 +++--- 4 files changed, 72 insertions(+), 11 deletions(-) create mode 100644 temporal-sdk/src/main/java/io/temporal/common/converter/PayloadValidationException.java create mode 100644 temporal-sdk/src/test/java/io/temporal/common/converter/PayloadValidationExceptionTest.java diff --git a/temporal-sdk/src/main/java/io/temporal/common/converter/PayloadValidationException.java b/temporal-sdk/src/main/java/io/temporal/common/converter/PayloadValidationException.java new file mode 100644 index 0000000000..e03cfa076f --- /dev/null +++ b/temporal-sdk/src/main/java/io/temporal/common/converter/PayloadValidationException.java @@ -0,0 +1,23 @@ +package io.temporal.common.converter; + +import io.temporal.failure.ApplicationFailure; + +/** Factory for failures raised when converting a value that violates its payload schema. */ +public final class PayloadValidationException { + private static final String MESSAGE = "Payload validation failed"; + private static final String TYPE = "PayloadValidationError"; + + private PayloadValidationException() {} + + /** + * Creates a non-retryable failure containing the aggregated validation violations. + * + *

The violations are stored as a single details value and serialized by the configured {@link + * DataConverter}. + * + * @param violations aggregated payload validation violations + */ + public static ApplicationFailure create(Object violations) { + return ApplicationFailure.newNonRetryableFailure(MESSAGE, TYPE, violations); + } +} diff --git a/temporal-sdk/src/test/java/io/temporal/common/converter/PayloadValidationExceptionTest.java b/temporal-sdk/src/test/java/io/temporal/common/converter/PayloadValidationExceptionTest.java new file mode 100644 index 0000000000..2f1cce51af --- /dev/null +++ b/temporal-sdk/src/test/java/io/temporal/common/converter/PayloadValidationExceptionTest.java @@ -0,0 +1,38 @@ +package io.temporal.common.converter; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +import com.google.common.reflect.TypeToken; +import io.temporal.api.failure.v1.Failure; +import io.temporal.failure.ApplicationFailure; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import org.junit.Test; + +public class PayloadValidationExceptionTest { + @Test + public void createReturnsNonRetryableApplicationFailureWithEncodedViolations() { + List> violations = + Collections.singletonList(Collections.singletonMap("path", "$.customer.contact.email")); + + ApplicationFailure applicationFailure = PayloadValidationException.create(violations); + + assertEquals("PayloadValidationError", applicationFailure.getType()); + assertTrue(applicationFailure.isNonRetryable()); + assertEquals("Payload validation failed", applicationFailure.getOriginalMessage()); + assertEquals(1, applicationFailure.getDetails().getSize()); + + DataConverter dataConverter = DefaultDataConverter.STANDARD_INSTANCE; + Failure encodedFailure = dataConverter.exceptionToFailure(applicationFailure); + assertEquals(1, encodedFailure.getApplicationFailureInfo().getDetails().getPayloadsCount()); + + ApplicationFailure decodedFailure = + (ApplicationFailure) dataConverter.failureToException(encodedFailure); + TypeToken>> violationsType = + new TypeToken>>() {}; + assertEquals( + violations, decodedFailure.getDetails().get(0, List.class, violationsType.getType())); + } +} diff --git a/temporal-sdk/src/test/java/io/temporal/internal/nexus/PayloadSerializerTest.java b/temporal-sdk/src/test/java/io/temporal/internal/nexus/PayloadSerializerTest.java index 380cbd7357..56d024e9b2 100644 --- a/temporal-sdk/src/test/java/io/temporal/internal/nexus/PayloadSerializerTest.java +++ b/temporal-sdk/src/test/java/io/temporal/internal/nexus/PayloadSerializerTest.java @@ -10,6 +10,7 @@ import io.temporal.common.converter.DataConverterException; import io.temporal.common.converter.DefaultDataConverter; import io.temporal.common.converter.EncodedValuesTest; +import io.temporal.common.converter.PayloadValidationException; import io.temporal.failure.ApplicationFailure; import io.temporal.payload.codec.PayloadCodecException; import java.lang.reflect.GenericArrayType; @@ -128,10 +129,9 @@ public void testDeserializeApplicationFailureIsPropagatedAsIs() { @Test public void testDeserializeNonRetryablePayloadValidationErrorIsNonRetryableBadRequest() { // The converter understood the input and rejected it, which makes this the caller's fault. - RuntimeException cause = new RuntimeException("field 'name' must not be empty"); ApplicationFailure original = - ApplicationFailure.newNonRetryableFailureWithCause( - "invalid input", PayloadSerializer.PAYLOAD_VALIDATION_ERROR_TYPE, cause); + PayloadValidationException.create( + Collections.singletonList(Collections.singletonMap("name", "must not be empty"))); PayloadSerializer serializer = failingSerializer(null, original); PayloadSerializer.Content content = payloadSerializer.serialize("test"); @@ -149,8 +149,8 @@ public void testDeserializeNonRetryablePayloadValidationErrorIsNonRetryableBadRe Assert.assertSame(original, causeFailure); Assert.assertEquals(PayloadSerializer.PAYLOAD_VALIDATION_ERROR_TYPE, causeFailure.getType()); Assert.assertTrue(causeFailure.isNonRetryable()); - Assert.assertEquals("invalid input", causeFailure.getOriginalMessage()); - Assert.assertSame(cause, causeFailure.getCause()); + Assert.assertEquals("Payload validation failed", causeFailure.getOriginalMessage()); + Assert.assertEquals(1, causeFailure.getDetails().getSize()); } @Test diff --git a/temporal-sdk/src/test/java/io/temporal/workflow/nexus/OperationInputDeserializationErrorPropagationTest.java b/temporal-sdk/src/test/java/io/temporal/workflow/nexus/OperationInputDeserializationErrorPropagationTest.java index e3f4bc179c..dbd2c76806 100644 --- a/temporal-sdk/src/test/java/io/temporal/workflow/nexus/OperationInputDeserializationErrorPropagationTest.java +++ b/temporal-sdk/src/test/java/io/temporal/workflow/nexus/OperationInputDeserializationErrorPropagationTest.java @@ -13,6 +13,7 @@ import io.temporal.client.WorkflowFailedException; import io.temporal.common.converter.DataConverter; import io.temporal.common.converter.DefaultDataConverter; +import io.temporal.common.converter.PayloadValidationException; import io.temporal.failure.ApplicationFailure; import io.temporal.failure.NexusOperationFailure; import io.temporal.failure.TimeoutFailure; @@ -22,6 +23,7 @@ import io.temporal.workflow.shared.TestWorkflows.TestWorkflow1; import java.lang.reflect.Type; import java.time.Duration; +import java.util.Collections; import java.util.Optional; import java.util.concurrent.atomic.AtomicInteger; import javax.annotation.Nonnull; @@ -45,7 +47,6 @@ public class OperationInputDeserializationErrorPropagationTest { "non-retryable-payload-validation-error"; private static final String RETRYABLE_PAYLOAD_VALIDATION_ERROR = "retryable-payload-validation-error"; - private static final String PAYLOAD_VALIDATION_ERROR_TYPE = "PayloadValidationError"; private static final String CODEC_FAILURE = "codec-failure"; private static final AtomicInteger deserializeAttempts = new AtomicInteger(); @@ -145,7 +146,7 @@ public void nonRetryablePayloadValidationErrorBecomesNonRetryableBadRequest() { Assert.assertEquals("PayloadValidationError", ((ApplicationFailure) cause).getType()); Assert.assertTrue( "expected the converter's message on the cause, got " + cause.getMessage(), - cause.getMessage().contains("intentional failure")); + cause.getMessage().contains("Payload validation failed")); Assert.assertEquals(1, deserializeAttempts.get()); Assert.assertEquals(0, operationInvocations.get()); @@ -288,11 +289,10 @@ private static RuntimeException failureFor(String mode) { case RETRYABLE_APPLICATION_FAILURE: return ApplicationFailure.newFailure("intentional failure", "TestFailure"); case NON_RETRYABLE_PAYLOAD_VALIDATION_ERROR: - return ApplicationFailure.newNonRetryableFailure( - "intentional failure", PAYLOAD_VALIDATION_ERROR_TYPE); + return PayloadValidationException.create( + Collections.singletonList("intentional validation failure")); case RETRYABLE_PAYLOAD_VALIDATION_ERROR: - return ApplicationFailure.newFailure( - "intentional failure", PAYLOAD_VALIDATION_ERROR_TYPE); + return ApplicationFailure.newFailure("intentional failure", "PayloadValidationError"); case CODEC_FAILURE: return new PayloadCodecException("intentional failure"); default: From df4e39c82790fee66c019f94195021eddd34c96e Mon Sep 17 00:00:00 2001 From: Roey Berman Date: Wed, 19 Aug 2026 14:23:13 -0700 Subject: [PATCH 2/3] Rename payload validation factory argument --- .../common/converter/PayloadValidationException.java | 10 +++++----- .../converter/PayloadValidationExceptionTest.java | 11 +++++------ 2 files changed, 10 insertions(+), 11 deletions(-) diff --git a/temporal-sdk/src/main/java/io/temporal/common/converter/PayloadValidationException.java b/temporal-sdk/src/main/java/io/temporal/common/converter/PayloadValidationException.java index e03cfa076f..47e803a141 100644 --- a/temporal-sdk/src/main/java/io/temporal/common/converter/PayloadValidationException.java +++ b/temporal-sdk/src/main/java/io/temporal/common/converter/PayloadValidationException.java @@ -10,14 +10,14 @@ public final class PayloadValidationException { private PayloadValidationException() {} /** - * Creates a non-retryable failure containing the aggregated validation violations. + * Creates a non-retryable failure containing payload validation details. * - *

The violations are stored as a single details value and serialized by the configured {@link + *

The details are stored as a single value and serialized by the configured {@link * DataConverter}. * - * @param violations aggregated payload validation violations + * @param details payload validation details */ - public static ApplicationFailure create(Object violations) { - return ApplicationFailure.newNonRetryableFailure(MESSAGE, TYPE, violations); + public static ApplicationFailure create(Object details) { + return ApplicationFailure.newNonRetryableFailure(MESSAGE, TYPE, details); } } diff --git a/temporal-sdk/src/test/java/io/temporal/common/converter/PayloadValidationExceptionTest.java b/temporal-sdk/src/test/java/io/temporal/common/converter/PayloadValidationExceptionTest.java index 2f1cce51af..06a20420f2 100644 --- a/temporal-sdk/src/test/java/io/temporal/common/converter/PayloadValidationExceptionTest.java +++ b/temporal-sdk/src/test/java/io/temporal/common/converter/PayloadValidationExceptionTest.java @@ -13,11 +13,11 @@ public class PayloadValidationExceptionTest { @Test - public void createReturnsNonRetryableApplicationFailureWithEncodedViolations() { - List> violations = + public void createReturnsNonRetryableApplicationFailureWithEncodedDetails() { + List> details = Collections.singletonList(Collections.singletonMap("path", "$.customer.contact.email")); - ApplicationFailure applicationFailure = PayloadValidationException.create(violations); + ApplicationFailure applicationFailure = PayloadValidationException.create(details); assertEquals("PayloadValidationError", applicationFailure.getType()); assertTrue(applicationFailure.isNonRetryable()); @@ -30,9 +30,8 @@ public void createReturnsNonRetryableApplicationFailureWithEncodedViolations() { ApplicationFailure decodedFailure = (ApplicationFailure) dataConverter.failureToException(encodedFailure); - TypeToken>> violationsType = + TypeToken>> detailsType = new TypeToken>>() {}; - assertEquals( - violations, decodedFailure.getDetails().get(0, List.class, violationsType.getType())); + assertEquals(details, decodedFailure.getDetails().get(0, List.class, detailsType.getType())); } } From 47ae3efb3723b72a704166aba331c3fc5cef453b Mon Sep 17 00:00:00 2001 From: Roey Berman Date: Thu, 20 Aug 2026 13:41:01 -0700 Subject: [PATCH 3/3] Rename payload validation factory method --- .../common/converter/PayloadValidationException.java | 2 +- .../common/converter/PayloadValidationExceptionTest.java | 6 ++++-- .../io/temporal/internal/nexus/PayloadSerializerTest.java | 2 +- .../OperationInputDeserializationErrorPropagationTest.java | 2 +- 4 files changed, 7 insertions(+), 5 deletions(-) diff --git a/temporal-sdk/src/main/java/io/temporal/common/converter/PayloadValidationException.java b/temporal-sdk/src/main/java/io/temporal/common/converter/PayloadValidationException.java index 47e803a141..459c2a4132 100644 --- a/temporal-sdk/src/main/java/io/temporal/common/converter/PayloadValidationException.java +++ b/temporal-sdk/src/main/java/io/temporal/common/converter/PayloadValidationException.java @@ -17,7 +17,7 @@ private PayloadValidationException() {} * * @param details payload validation details */ - public static ApplicationFailure create(Object details) { + public static ApplicationFailure newPayloadValidationException(Object details) { return ApplicationFailure.newNonRetryableFailure(MESSAGE, TYPE, details); } } diff --git a/temporal-sdk/src/test/java/io/temporal/common/converter/PayloadValidationExceptionTest.java b/temporal-sdk/src/test/java/io/temporal/common/converter/PayloadValidationExceptionTest.java index 06a20420f2..bca471d72b 100644 --- a/temporal-sdk/src/test/java/io/temporal/common/converter/PayloadValidationExceptionTest.java +++ b/temporal-sdk/src/test/java/io/temporal/common/converter/PayloadValidationExceptionTest.java @@ -13,11 +13,13 @@ public class PayloadValidationExceptionTest { @Test - public void createReturnsNonRetryableApplicationFailureWithEncodedDetails() { + public void + newPayloadValidationExceptionReturnsNonRetryableApplicationFailureWithEncodedDetails() { List> details = Collections.singletonList(Collections.singletonMap("path", "$.customer.contact.email")); - ApplicationFailure applicationFailure = PayloadValidationException.create(details); + ApplicationFailure applicationFailure = + PayloadValidationException.newPayloadValidationException(details); assertEquals("PayloadValidationError", applicationFailure.getType()); assertTrue(applicationFailure.isNonRetryable()); diff --git a/temporal-sdk/src/test/java/io/temporal/internal/nexus/PayloadSerializerTest.java b/temporal-sdk/src/test/java/io/temporal/internal/nexus/PayloadSerializerTest.java index 56d024e9b2..4c528646df 100644 --- a/temporal-sdk/src/test/java/io/temporal/internal/nexus/PayloadSerializerTest.java +++ b/temporal-sdk/src/test/java/io/temporal/internal/nexus/PayloadSerializerTest.java @@ -130,7 +130,7 @@ public void testDeserializeApplicationFailureIsPropagatedAsIs() { public void testDeserializeNonRetryablePayloadValidationErrorIsNonRetryableBadRequest() { // The converter understood the input and rejected it, which makes this the caller's fault. ApplicationFailure original = - PayloadValidationException.create( + PayloadValidationException.newPayloadValidationException( Collections.singletonList(Collections.singletonMap("name", "must not be empty"))); PayloadSerializer serializer = failingSerializer(null, original); PayloadSerializer.Content content = payloadSerializer.serialize("test"); diff --git a/temporal-sdk/src/test/java/io/temporal/workflow/nexus/OperationInputDeserializationErrorPropagationTest.java b/temporal-sdk/src/test/java/io/temporal/workflow/nexus/OperationInputDeserializationErrorPropagationTest.java index dbd2c76806..ac826b94e2 100644 --- a/temporal-sdk/src/test/java/io/temporal/workflow/nexus/OperationInputDeserializationErrorPropagationTest.java +++ b/temporal-sdk/src/test/java/io/temporal/workflow/nexus/OperationInputDeserializationErrorPropagationTest.java @@ -289,7 +289,7 @@ private static RuntimeException failureFor(String mode) { case RETRYABLE_APPLICATION_FAILURE: return ApplicationFailure.newFailure("intentional failure", "TestFailure"); case NON_RETRYABLE_PAYLOAD_VALIDATION_ERROR: - return PayloadValidationException.create( + return PayloadValidationException.newPayloadValidationException( Collections.singletonList("intentional validation failure")); case RETRYABLE_PAYLOAD_VALIDATION_ERROR: return ApplicationFailure.newFailure("intentional failure", "PayloadValidationError");