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:
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 Throwable
s.
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: Throwable
s are serialized as POJOs, and then the issue is due to:
Throwable.cause
field is set tothis
by default (to indicate no "cause" has been set)- 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.