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.IllegalArgumentException, but it does not work for my -- identical -- riskop.MyIllegalArgumentException.

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:

        IllegalArgumentException e1 = new IllegalArgumentException();
        try {
            toFromJson.toFromJson(e1);
        }
        catch (IllegalArgumentException e2) {
            // okay, expected
            Assertions.assertEquals(e1.getMessage(), e2.getMessage());
        }
        catch (Throwable t) {
            throw new RuntimeException(t);
        }

This is not working, but I think it should:

        MyIllegalArgumentException e1 = new MyIllegalArgumentException();
        try {
            toFromJson.toFromJson(e1);
        }
        catch (MyIllegalArgumentException e2) {
            // okay, expected
            Assertions.assertEquals(e1.getMessage(), e2.getMessage());
        }
        catch (Throwable t) {
            throw new RuntimeException(t);
        }

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

There's no difference between MyIllegalArgumentException and the JDK's one:

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

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

just execute mvn clean install

stacktrace:

1619 [INFO]  T E S T S
1619 [INFO] -------------------------------------------------------
1939 [INFO] Running riskop.MyTest
11:08:44.999 [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.
11:08:45.056 [main] INFO org.springframework.boot.test.context.SpringBootTestContextBootstrapper -- Found @SpringBootConfiguration riskop.Start for test class riskop.MyTest

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

 :: Spring Boot ::                (v3.5.0)

2025-06-17T11:08:45.273+02:00  INFO 524080 --- [           main] riskop.MyTest                            : Starting MyTest using Java 21.0.1 with PID 524080 (started by riskop in /home/riskop/IdeaProjects/jackson_throwable_to_from_json)
2025-06-17T11:08:45.274+02:00  INFO 524080 --- [           main] riskop.MyTest                            : No active profile set, falling back to 1 default profile: "default"
2025-06-17T11:08:45.625+02:00  INFO 524080 --- [           main] riskop.MyTest                            : Started MyTest in 0.503 seconds (process running for 0.971)
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
OpenJDK 64-Bit Server VM warning: Sharing is only supported for boot loader classes because bootstrap classpath has been appended
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
3103 [ERROR] Tests run: 2, Failures: 0, Errors: 1, Skipped: 0, Time elapsed: 1.152 s <<< FAILURE! -- in riskop.MyTest
3103 [ERROR] riskop.MyTest.TestMyIAE -- Time elapsed: 0.007 s <<< ERROR!
java.lang.RuntimeException: com.fasterxml.jackson.databind.exc.InvalidDefinitionException: Direct self-reference leading to cycle (through reference chain: riskop.MyIllegalArgumentException["cause"])
        at riskop.MyTest.TestMyIAE(MyTest.java:40)
        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.MyIllegalArgumentException["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.TestMyIAE(MyTest.java:33)
        ... 3 more

3118 [INFO] 
3118 [INFO] Results:
3118 [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.IllegalArgumentException ( for which serialization / deserialization works) and the riskop.MyIllegalArgumentException ( for which serialization fails ). The riskop.MyIllegalArgumentException is actually a copy of the java.lang.IllegalArgumentException.

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

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

This "riskop.MyIllegalArgumentException" 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 fail to serialize ( while JDK exceptions are not ).

Comment From: JooHyukKim

@riskop if u check the stacktrace u shared. It says

direct self reference leading a cycle...

Can you solve that on ur side first and see how behavior changes?

Comment From: riskop

I replaced NullPointerException with IllegalArgumentException because in NullPointerException there was a native method.

The strange behaviour is still the same.

As for solving the "direct self reference leading a cycle..." on my side; yes, I can. For example, these are all working:

        MyIllegalArgumentException e1 = new MyIllegalArgumentException("some msg", null);
        MyIllegalArgumentException e1 = new MyIllegalArgumentException(new RuntimeException());
        MyIllegalArgumentException e1 = new MyIllegalArgumentException();
        e1.initCause(null);

But this is not:

        MyIllegalArgumentException e1 = new MyIllegalArgumentException(new MyIllegalArgumentException());

However I would like to replace a framework in our application, and I need to transfer exceptions transparently, I would not like to manipulate the exceptions before serializing and sending them through the rest channel.

Comment From: JooHyukKim

Idk if passing around Throwable over network is strictly neccessary... but @riskop Try playing around with below configurations, this might work.?

        ObjectWriter w = MAPPER.writer()
                .without(SerializationFeature.FAIL_ON_SELF_REFERENCES)
                .with(SerializationFeature.WRITE_SELF_REFERENCES_AS_NULL)

Comment From: cowtowncoder

Interesting. Your exception may be incorrectly defined so that cause is exception itself: that would be Wrong. This based on failure message. cause needs to be either null or something that is not part of cyclic reference (otherwise there will be failure for too deep recursion).

Although how cause could become this is... not clear. It cannot be passed in constructor, for example; something is probably calling initCause() like:

exception.initCause(exception);

(this assuming Jackson's error indication is correct).

Comment From: riskop

What do you mean by "incorrectly defined"? The MyIllegalArgumentException.class is an exact copy of java.lang.IllegalArgumentException. And I instantiate it the most straightforward way:

MyIllegalArgumentException e1 = new MyIllegalArgumentException();

The strange thing is that the cause is a direct self reference in MyIllegalArgumentException and in java.lang.IllegalArgumentException 's case as well. Yet serialization works only for the latter.

Screenshot from the debugger:

Image

Image

Comment From: cowtowncoder

By "incorrectly defined" (or constructed) I meant there was accidental loop either in definition, or constructed instance.

But now I vaguely remember that plain Exception (or Throwable?) might use this as marker to avoid using null -- so it is probably not your code at fault here. And if so, Jackson does have handling for it, but not sure why it won't work in this case.

One other thing to try: disable Polymorphic handling and see if regular ser/deser works. I am guessing there may be difference, and as I mentioned, I am not sure polymorphic handling will work for Exceptions. I am not saying not to use PM ser/deser, but just that it'd be good to see if that is part of what triggers the issue.

Comment From: cowtowncoder

Oh, also: I would recommend against changing visibility rules, esp this:

objectMapper.setVisibility(PropertyAccessor.FIELD, JsonAutoDetect.Visibility.ANY);

unless you clearly know it is needed. It could be picking up cause field, changing introspection handling wrt Throwables.

Ideally that should not matter, but technically Throwable support uses mostly regular POJO support (but with some tweaks)

Comment From: riskop

Yikes, if I change Config to NOT setting the below:

    //objectMapper.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.NONE);

Then both tests in MyTest suit succeeds. Furthermore, tests in MyTest2 succeeds as well!

According to your advice I tried to change Config to NOT setting

    //objectMapper.setVisibility(PropertyAccessor.FIELD, JsonAutoDetect.Visibility.ANY);

too, but in that case MyTest2.TestMyException fails; the main exception's message is not transferred then.

So it seems that the below 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);

works with my -- albeit very limited -- test cases.

Thanks for the hint!

Comment From: JooHyukKim

@riskop closing issue then?

Comment From: cowtowncoder

Note, MyIllegalArgumentException is like so (minus comments):

public class MyIllegalArgumentException extends RuntimeException {
    public MyIllegalArgumentException() {
        super();
    }

    public MyIllegalArgumentException(String s) {
        super(s);
    }

    public MyIllegalArgumentException(String message, Throwable cause) {
        super(message, cause);
    }

    public MyIllegalArgumentException(Throwable cause) {
        super(cause);
    }
}

ideally we should have this working regardless of visibility changes.

Looks like failure is specifically in SERIALIZATION, from stack trace.

Comment From: cowtowncoder

Ok, after looking at this a bit, I figured it out. So: Throwables are serialized as POJOs, and then the issue is due to:

  1. Throwable.cause field is set to this by default (to indicate no "cause" has been set)
  2. But Throwable.getCause() has this logic:
    public synchronized Throwable getCause() {
        return (cause==this ? null : cause);
    }

so that throwable.getCause() will "convert" this into null -- but direct Field access will not.

So if you forcibly prevent auto-detection of Methods (getters like getCause()) but allow auto-detection of any and all fields (like cause), we get this issue.

But this gives me an idea of how to possibly tackle the problem: will first check in failing test case.

Comment From: cowtowncoder

Fixed serialization side of things; not sure if deserialization has other problems: if so, please file a follow-up issue.