Issue Description

When using Spring AI, I encountered a serialization failure for Java 8 date/time types (such as java.time.Duration). Despite correctly including the jackson-datatype-jsr310 dependency in my project, the following error occurs at runtime:

Caused by: com.fasterxml.jackson.databind.exc.InvalidDefinitionException: Java 8 date/time type `java.time.Duration` not supported by default: add Module "com.fasterxml.jackson.datatype:jackson-datatype-jsr310" to enable handling (through reference chain: org.springframework.ai.chat.model.ChatResponse["metadata"]->org.springframework.ai.chat.metadata.ChatResponseMetadata["rateLimit"]->org.springframework.ai.chat.metadata.EmptyRateLimit["tokensReset"])

Root Cause

Through debugging, I discovered that the issue lies in the org.springframework.ai.util.JacksonUtils class's instantiateAvailableModules() method. This method uses ClassUtils.forName() to attempt loading the JavaTimeModule class, but fails due to class loader isolation issues.

The specific reasons are:

  1. Spring Boot applications use multiple class loaders:
  2. jdk.internal.loader.ClassLoaders$AppClassLoader - JDK's default application class loader
  3. org.springframework.boot.loader.LaunchedURLClassLoader - Spring Boot's class loader for application classes and dependencies

  4. ClassUtils.getDefaultClassLoader() returns the thread context class loader, which is AppClassLoader, not the LaunchedURLClassLoader that can see the application dependencies.

  5. Due to class loader visibility rules (parent class loaders cannot see classes loaded by child class loaders), AppClassLoader cannot see the JavaTimeModule class loaded by LaunchedURLClassLoader.

  6. Verification tests:

  7. Class.forName("com.fasterxml.jackson.datatype.jsr310.JavaTimeModule") successfully loads the class because it uses the caller's class loader (LaunchedURLClassLoader)
  8. ClassUtils.forName("com.fasterxml.jackson.datatype.jsr310.JavaTimeModule", null) fails to load the class because it uses AppClassLoader
  9. Class.forName("com.fasterxml.jackson.datatype.jsr310.JavaTimeModule", false, ClassUtils.getDefaultClassLoader()) also fails to load the class

Steps to Reproduce

  1. Create a Spring Boot application with Spring AI dependencies
  2. Ensure jackson-datatype-jsr310 dependency is added
  3. Use Spring AI's ChatClient to send requests and handle responses
  4. Attempt to serialize a ChatResponse object containing java.time.Duration fields

Temporary Workaround

Modify the ModelOptionsUtils.OBJECT_MAPPER static field via reflection to directly register JavaTimeModule:

@Configuration
public class JacksonConfig {

    @PostConstruct
    public void fixModelOptionsUtilsObjectMapper() {
        try {
            // Get ModelOptionsUtils class
            Class<?> modelOptionsUtilsClass = Class.forName("org.springframework.ai.model.ModelOptionsUtils");

            // Get OBJECT_MAPPER field
            Field objectMapperField = modelOptionsUtilsClass.getDeclaredField("OBJECT_MAPPER");
            objectMapperField.setAccessible(true);

            // Get current ObjectMapper instance
            ObjectMapper currentMapper = (ObjectMapper) objectMapperField.get(null);

            // Create JavaTimeModule instance directly, avoiding class loader issues
            JavaTimeModule javaTimeModule = new JavaTimeModule();

            // Register module
            currentMapper.registerModule(javaTimeModule);

            System.out.println("Successfully registered JavaTimeModule to ModelOptionsUtils.OBJECT_MAPPER");
        } catch (Exception e) {
            System.err.println("Failed to modify ModelOptionsUtils.OBJECT_MAPPER: " + e.getMessage());
            e.printStackTrace();
        }
    }
}

Suggested Fix

I recommend modifying the JacksonUtils.instantiateAvailableModules() method to use Class.forName() instead of ClassUtils.forName(), or explicitly specify the current class's class loader:

try {
    Class<? extends com.fasterxml.jackson.databind.Module> javaTimeModuleClass = 
        (Class<? extends Module>) Class.forName("com.fasterxml.jackson.datatype.jsr310.JavaTimeModule");
    com.fasterxml.jackson.databind.Module javaTimeModule = BeanUtils.instantiateClass(javaTimeModuleClass);
    modules.add(javaTimeModule);
}
catch (ClassNotFoundException ex) {
    // jackson-datatype-jsr310 not available
}

Or alternatively:

try {
    ClassLoader currentClassLoader = JacksonUtils.class.getClassLoader();
    Class<? extends com.fasterxml.jackson.databind.Module> javaTimeModuleClass = 
        (Class<? extends Module>) Class.forName("com.fasterxml.jackson.datatype.jsr310.JavaTimeModule", 
                                               true, 
                                               currentClassLoader);
    com.fasterxml.jackson.databind.Module javaTimeModule = BeanUtils.instantiateClass(javaTimeModuleClass);
    modules.add(javaTimeModule);
}
catch (ClassNotFoundException ex) {
    // jackson-datatype-jsr310 not available
}

Environment Information

  • Spring AI version: 1.0.0-M7.MT2
  • Spring Boot version: 2.7.18
  • Java version: jdk17
  • Jackson version: 2.18.3

Related Code

The issue appears in the following classes:

  1. org.springframework.ai.util.JacksonUtils - Responsible for loading Jackson modules
  2. org.springframework.ai.model.ModelOptionsUtils - Contains the static OBJECT_MAPPER field
  3. org.springframework.util.ClassUtils - Provides class loader utility methods

Additional Information

This issue is caused by a mismatch between Spring Boot's class loader hierarchy and Spring Framework's class loader usage patterns. In Spring Boot applications, application classes and dependencies are loaded by LaunchedURLClassLoader, while Spring Framework's utility class ClassUtils defaults to using the thread context class loader (AppClassLoader), resulting in an inability to find classes in application dependencies.

Comment From: markpollack

Just as additional background, The use of ClassUtils was part of bringing in the kotlin support. See https://github.com/spring-projects/spring-ai/commit/f21b8a42b521f7a345b3651065fb88613c3add58

@sdeleuze can you advise?

Comment From: sdeleuze

@luwanglin Can you please provide a self-contained reproducer?

Comment From: Hukeqing

Same problem, why using log.debug with json stringify in org.springframework.ai.chat.client.advisor.SimpleLoggerAdvisor#observeAfter

I am using those code to avoid the problem

final Field unsafeField = Unsafe.class.getDeclaredField("theUnsafe");
unsafeField.setAccessible(true);
final Unsafe unsafe = (Unsafe) unsafeField.get(null);

final Field theField = ModelOptionsUtils.class.getDeclaredField("OBJECT_MAPPER");
final Object staticFieldBase = unsafe.staticFieldBase(theField);
final long staticFieldOffset = unsafe.staticFieldOffset(theField);
unsafe.putObject(staticFieldBase, staticFieldOffset, JsonMapper.builder()
    .disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)
    .disable(SerializationFeature.FAIL_ON_EMPTY_BEANS)
    .addModules(new JavaTimeModule())
    .build());

Comment From: markpollack

@ericbottard can you take a look?