Search before asking

  • [x] I searched in the issues and found nothing similar.

Describe the bug

I am trying to serialize and then deserialize an exception. It works in case of a java.lang.NullPointerException, but it does not work for my -- identical -- riskop.NullPointerException.

Version Information

Spring boot 3.5.0, Jackson 2.19.0

Reproduction

This is my ObjectMapper config:

        ObjectMapper objectMapper = new ObjectMapper();
        objectMapper.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.NONE);
        objectMapper.setVisibility(PropertyAccessor.FIELD, JsonAutoDetect.Visibility.ANY);
        PolymorphicTypeValidator ptv = BasicPolymorphicTypeValidator.builder()
                        .allowIfSubType(Object.class)
                        .build();
        objectMapper.activateDefaultTyping(ptv, ObjectMapper.DefaultTyping.NON_FINAL, JsonTypeInfo.As.PROPERTY);

This is how I serialize / deserialize:

    public void toFromJson(Throwable in) throws Throwable {
        String json1 = objectMapper.writeValueAsString(in);
        Throwable out = objectMapper.readValue(json1, Throwable.class);
        String json2 = objectMapper.writeValueAsString(out);
        throw out;
    }

This is working:

        java.lang.NullPointerException npe = new java.lang.NullPointerException();
        try {
            toFromJson.toFromJson(npe);
        }
        catch (java.lang.NullPointerException npe2) {
            // okay, expected
            Assertions.assertEquals(npe.getMessage(), npe2.getMessage());
        }
        catch (Throwable t) {
            throw new RuntimeException();
        }

This is not working, but I think it should:

        NullPointerException npe = new NullPointerException();
        try {
            toFromJson.toFromJson(npe);
        }
        catch (NullPointerException npe2) {
            // okay, expected
            Assertions.assertEquals(npe.getMessage(), npe2.getMessage());
        }
        catch (Throwable t) {
            throw new RuntimeException();
        }

There is a mini project for demonstrating the problem: https://github.com/riskop/jackson_throwable_to_from_json

There's no difference between my NullPointerException and the JDK's one:

JDK's NullPointerException: https://github.com/openjdk/jdk21/blob/master/src/java.base/share/classes/java/lang/NullPointerException.java

My NullPointerException https://github.com/riskop/jackson_throwable_to_from_json/blob/main/src/main/java/riskop/NullPointerException.java

just execute mvn clean install

stacktrace:

1654 [INFO]  T E S T S
1654 [INFO] -------------------------------------------------------
1991 [INFO] Running riskop.MyTest
06:55:58.100 [main] INFO org.springframework.test.context.support.AnnotationConfigContextLoaderUtils -- Could not detect default configuration classes for test class [riskop.MyTest]: MyTest does not declare any static, non-private, non-final, nested classes annotated with @Configuration.
06:55:58.160 [main] INFO org.springframework.boot.test.context.SpringBootTestContextBootstrapper -- Found @SpringBootConfiguration riskop.Start for test class riskop.MyTest

  .   ____          _            __ _ _
 /\\ / ___'_ __ _ _(_)_ __  __ _ \ \ \ \
( ( )\___ | '_ | '_| | '_ \/ _` | \ \ \ \
 \\/  ___)| |_)| | | | | || (_| |  ) ) ) )
  '  |____| .__|_| |_|_| |_\__, | / / / /
 =========|_|==============|___/=/_/_/_/

 :: Spring Boot ::                (v3.5.0)

2025-06-17T06:55:58.377+02:00  INFO 509745 --- [           main] riskop.MyTest                            : Starting MyTest using Java 21.0.1 with PID 509745 (started by riskop in /home/riskop/IdeaProjects/jackson_throwable_to_from_json)
2025-06-17T06:55:58.378+02:00  INFO 509745 --- [           main] riskop.MyTest                            : No active profile set, falling back to 1 default profile: "default"
2025-06-17T06:55:58.729+02:00  INFO 509745 --- [           main] riskop.MyTest                            : Started MyTest in 0.501 seconds (process running for 0.994)
Mockito is currently self-attaching to enable the inline-mock-maker. This will no longer work in future releases of the JDK. Please add Mockito as an agent to your build as described in Mockito's documentation: https://javadoc.io/doc/org.mockito/mockito-core/latest/org.mockito/org/mockito/Mockito.html#0.3
WARNING: A Java agent has been loaded dynamically (/home/riskop/.m2/repository/net/bytebuddy/byte-buddy-agent/1.17.5/byte-buddy-agent-1.17.5.jar)
WARNING: If a serviceability tool is in use, please run with -XX:+EnableDynamicAgentLoading to hide this warning
WARNING: If a serviceability tool is not in use, please run with -Djdk.instrument.traceUsage for more information
WARNING: Dynamic loading of agents will be disallowed by default in a future release
OpenJDK 64-Bit Server VM warning: Sharing is only supported for boot loader classes because bootstrap classpath has been appended
3161 [ERROR] Tests run: 2, Failures: 0, Errors: 1, Skipped: 0, Time elapsed: 1.159 s <<< FAILURE! -- in riskop.MyTest
3161 [ERROR] riskop.MyTest.TestMyNPE -- Time elapsed: 0.008 s <<< ERROR!
java.lang.RuntimeException: com.fasterxml.jackson.databind.exc.InvalidDefinitionException: Direct self-reference leading to cycle (through reference chain: riskop.NullPointerException["cause"])
        at riskop.MyTest.TestMyNPE(MyTest.java:41)
        at java.base/java.lang.reflect.Method.invoke(Method.java:580)
        at java.base/java.util.ArrayList.forEach(ArrayList.java:1596)
        at java.base/java.util.ArrayList.forEach(ArrayList.java:1596)
Caused by: com.fasterxml.jackson.databind.exc.InvalidDefinitionException: Direct self-reference leading to cycle (through reference chain: riskop.NullPointerException["cause"])
        at com.fasterxml.jackson.databind.exc.InvalidDefinitionException.from(InvalidDefinitionException.java:77)
        at com.fasterxml.jackson.databind.SerializerProvider.reportBadDefinition(SerializerProvider.java:1359)
        at com.fasterxml.jackson.databind.ser.BeanPropertyWriter._handleSelfReference(BeanPropertyWriter.java:953)
        at com.fasterxml.jackson.databind.ser.BeanPropertyWriter.serializeAsField(BeanPropertyWriter.java:726)
        at com.fasterxml.jackson.databind.ser.std.BeanSerializerBase.serializeFields(BeanSerializerBase.java:760)
        at com.fasterxml.jackson.databind.ser.std.BeanSerializerBase.serializeWithType(BeanSerializerBase.java:643)
        at com.fasterxml.jackson.databind.ser.impl.TypeWrappedSerializer.serialize(TypeWrappedSerializer.java:32)
        at com.fasterxml.jackson.databind.ser.DefaultSerializerProvider._serialize(DefaultSerializerProvider.java:503)
        at com.fasterxml.jackson.databind.ser.DefaultSerializerProvider.serializeValue(DefaultSerializerProvider.java:342)
        at com.fasterxml.jackson.databind.ObjectMapper._writeValueAndClose(ObjectMapper.java:4859)
        at com.fasterxml.jackson.databind.ObjectMapper.writeValueAsString(ObjectMapper.java:4079)
        at riskop.ToFromJson.toFromJson(ToFromJson.java:18)
        at riskop.MyTest.TestMyNPE(MyTest.java:34)
        ... 3 more

3181 [INFO] 
3181 [INFO] Results:
3181 [INFO] 
3181 [ERROR] Errors: 
3181 [ERROR]   MyTest.TestMyNPE:41 Runtime com.fasterxml.jackson.databind.exc.InvalidDefinitionException: Direct self-reference leading to cycle (through reference chain: riskop.NullPointerException["cause"])
3181 [INFO] 
3181 [ERROR] Tests run: 2, Failures: 0, Errors: 1, Skipped: 0
3181 [INFO] 

Expected behavior

No response

Additional context

background:

I would like to rewrite a http-invoker based communication framework with REST technology. Http-invoker handled exceptions transparently, if the server side caused an exception, the client side just received it.

I am trying to reproduce that behaviour. In case of errors the server component would serialize the exception to json, pass it to the client ( with rest ) then the client would restore the original exception from json.

Comment From: JooHyukKim

This is working:

    java.lang.NullPointerException npe = new java.lang.NullPointerException();
    try {
        toFromJson.toFromJson(npe);
    }
    catch (java.lang.NullPointerException npe2) {
        // okay, expected
        Assertions.assertEquals(npe.getMessage(), npe2.getMessage());
    }
    catch (Throwable t) {
        throw new RuntimeException();
    }

This is not working, but I think it should:

    NullPointerException npe = new NullPointerException();
    try {
        toFromJson.toFromJson(npe);
    }
    catch (NullPointerException npe2) {
        // okay, expected
        Assertions.assertEquals(npe.getMessage(), npe2.getMessage());
    }
    catch (Throwable t) {
        throw new RuntimeException();
    }

These two cases only differ by FQN's of NullPointerException. Try checking which NullPointerException you are importing?

Comment From: cowtowncoder

Ok: attempts to use Polymorphic (De)Serialization with Throwable are probably not going to work very well, so there may be fundamental problems.

But one thing to include in the description is the stack trace (or at least first 20 or so lines) of failure: otherwise "it fails" does not tell anything actionable. I understand there is a separate repo but ideally I wouldn't have to clone and build a project just to see the stack trace. Stack trace may well give clues as to the issue -- my guess is your own Exception might be missing usable constructor (one Jackson Throwable deserializer can use).

Comment From: riskop

Sorry for not including the test output, it's inserted now.

The strange thing is that as far as I understand there is no difference between the java.lang.NullPointerException ( for which serialization / deserialization works) and the riskop.NullPointerException ( for which serialization fails ). The risko.NullPointerException is actually a copy of the java.lang.NullPointerException.

JDK's NullPointerException: https://github.com/openjdk/jdk21/blob/master/src/java.base/share/classes/java/lang/NullPointerException.java

My NullPointerException https://github.com/riskop/jackson_throwable_to_from_json/blob/main/src/main/java/riskop/NullPointerException.java

This "riskop.NullPointerException" might seem strange. Of course this is not what I would like to use in the project, it is a simplification of the problem I have: my own exceptions fails to serialize ( while JDK exceptions is not ).