Skip to content

Spring Boot 4.2.0-RC2 - #16871

Open
codeconsole wants to merge 49 commits into
apache:9.1.xfrom
codeconsole:deps/spring-boot-4.2.0-RC2
Open

codeconsole wants to merge 49 commits into
apache:9.1.xfrom
codeconsole:deps/spring-boot-4.2.0-RC2

Conversation

@codeconsole

@codeconsole codeconsole commented Nov 5, 2026 •

Copy link
Copy Markdown
Contributor

4.2.0 is next, due November 19, 2026

Moves 9.1.x from Spring Boot 4.2.0-RC1, which #16738 brought in, to Spring Boot 4.2.0-RC2.

Managed versions that change

Spring Boot 4.2.0-RC1 Spring Boot 4.2.0-RC2
Spring Framework 7.1.0-RC1 7.1.0-RC2
Micrometer 1.18.0-RC1 1.18.0-RC2
Micrometer Tracing 1.8.0-RC1 1.8.0-RC2
Hibernate ORM 7.4.13.Final 7.4.14.Final
Hibernate Validator 9.1.5.Final 9.1.6.Final
Jackson 3 / Jackson 2 3.1.7 / 2.21.7 3.1.8 / 2.21.8
Logback 1.6.5 1.6.6
Netty 4.2.19.Final 4.2.20.Final
json-smart 2.6.0 2.6.1
Byte Buddy 1.18.15 1.18.17
AspectJ 1.9.25.1 1.9.27
Native Build Tools 1.1.14 1.1.18

Spring Security 7.2.0-RC1, Spring Data 2026.1.0-RC1, Reactor 2026.0.0-RC1, Tomcat 11.0.27, the MongoDB driver 5.13.0, graphql-java 26.1 and Jedis 8.0.2 stay where they are, so the graphql-java tripwire and the LEGAL-666 SBOM mapping do not move.

grails-bom

  • ASM goes from 9.10.1 to 9.11.1. json-smart 2.6.1 depends on ASM 9.11.1 through accessors-smart 2.6.1. That version won over the 9.10.1 the BOM managed, so validateDependencyVersions failed in every project that has json-smart on its classpath, which spring-boot-starter-test puts there through json-path.

  • The Jackson pins stay at 2.22.3 and 3.2.3 in this PR, and 8.0 (fix): Jackson 2.22.4 and 3.2.4 for CVE-2026-110879 #16872 moves them to 2.22.4 and 3.2.4. Their comments now name the 2.21.8 and 3.1.8 that Spring Boot 4.2.0-RC2 manages. Both pinned versions are affected by CVE-2026-110879 in jackson-databind, and 3.2.3 also by CVE-2026-110729 in jackson-core. Spring Boot's 2.21.8 and 3.1.8 are not affected, but the pins override them. The 2.22.4 bump made on 7.x for that CVE was lost when 7.2.x was merged into 8.0.x, so 8.0 (fix): Jackson 2.22.4 and 3.2.4 for CVE-2026-110879 #16872 makes it on 8.0.x and it reaches 9.1.x with the merge-up. (The commit that updates these comments says no advisory affects the pins; that was wrong.) Jackson 2 stays on the 2.22 line because Groovy YAML 6.0.0 depends on 2.22.2, and Jackson 3 stays on the 3.2 line so that moving to Spring Boot 4.2 does not take it back to 3.1.

  • Hibernate ORM 7 is no longer pinned. It was pinned in dependencies.gradle, so every Spring Boot upgrade that moved Hibernate needed a matching manual bump. Now grails-hibernate7-bom reads the version spring-boot-dependencies manages for hibernate-core, 7.4.14.Final with RC2. It strictly re-declares hibernate-core, hibernate-envers and hibernate-jcache at that version, under Spring Boot's hibernate.version, so enforcedPlatform consumers are still forced to it. The published POM is unchanged apart from the order of those entries.

    When Spring Boot lags behind a Hibernate ORM 7 release Grails needs, dependencies.gradle can name that release instead:

    hibernate7VersionOverride = '7.4.15.Final'

    The build fails while the override is not newer than Spring Boot's version, so it cannot silently downgrade Hibernate once Spring Boot catches up:

    hibernate7VersionOverride 7.4.13.Final is not newer than the Hibernate ORM 7.4.14.Final that spring-boot-dependencies manages: remove it from dependencies.gradle
    

    hibernate-models is strictly re-declared too, and Spring Boot does not manage it. Because it now moves without anyone editing the Hibernate block, the build also fails when hibernate-models.version differs from the version the managed Hibernate ORM release depends on:

    Hibernate ORM 8.0.0.Beta4 depends on hibernate-models 1.3.4, but dependencies.gradle manages 1.1.2: set hibernate-models.version to 1.3.4
    

    Applications still override the version with ext['hibernate.version'], as before.

Embedded MongoDB from an executable jar

With Spring Boot 4.2.0-RC2, an application using the in-memory backend and run with java -jar could lose its database as it started and then hang when stopped. Stopping the application without this fix:

Hung
Boot 4.2.0-RC2, Netty 4.2.20, as RC2 manages them 3 of 3
Boot 4.2.0-RC2, Netty 4.2.19 1 of 6
Boot 4.2.0-RC1's ClearCachesApplicationListener, Netty 4.2.20 0 of 6

Since spring-projects/spring-boot#52582, that listener finds clearCache() on Spring Boot's class loader, so it closes the jars of an executable jar when the context has refreshed, and a class being loaded at that moment fails with IllegalStateException: Zip file closed. The failure also needs spring-projects/spring-boot#52246, which has the class loader look through those same jars directly, and which only 4.2 has: with the loader of 4.1.3-SNAPSHOT, the same application stopped cleanly 10 times out of 10. It is reported as spring-projects/spring-boot#52634, with a fix proposed in spring-projects/spring-boot#52635. Netty only changes the odds, so the message of 285fa77, which leads with Netty 4.2.20, overstates its part.

The MongoDB driver connects just before that moment, and reading its first bytes allocates the event loop's first buffer. Netty logs the failure, Logback fails the same way to load a class to log it, and that ends the event loop's thread. The driver gets no answer, and stopping the server waits forever for its connection on that thread to close. It also stopped traceNativeMetadata and trainAotCache from finishing.

The server now answers one isMaster as it starts, and allocates buffers until Netty's leak detection has tracked one, which it does to about one buffer in 128 at random and which loads classes of its own. So the event loop has loaded what the first client needs well before that moment. The race itself remains for anything else loading a class right then: with HTTP requests hitting a plain application on Tomcat during startup, 18 of 30 starts logged it, against 0 of 30 on Spring Boot 4.1.2, and requests answered normally afterwards. If Spring Boot's fix lands in 4.2.0, this can be removed.

Vulnerability scan

  • The CVE-2026-113602 exclusion for net.minidev:json-smart goes. json-smart 2.6.1 rejects a literal 0x1A after the value in strict mode (netplex/json-smart-v2#305), which fixes CVE-2026-113602. The exclusion was to be removed once a patched release shipped, and 2.6.1 is that release.
  • The CVE-2026-47842 exclusion for spring-security-crypto now names Spring Boot 4.2.0-RC2, which still manages Spring Security 7.2.0-RC1.

Features Spring Boot 4.2 makes possible

A login no longer redirects to a request the browser sent on its own

Spring Security 7.2's configuration DSL does not save requests for /favicon.*, /apple-touch-icon*.png, /manifest.json, /manifest.webmanifest, /browserconfig.xml or Chrome DevTools' /.well-known/appspecific/com.chrome.devtools.json. The Spring Security plugin builds its beans without the DSL and saved every denied request, so a login could land on one of these. Against the functional test app, a login after a denied request for /.well-known/appspecific/com.chrome.devtools.json redirected to /.well-known/appspecific/com.chrome.devtools.json?continue.

The plugin now leaves the same paths out, with a setting of its own, since the DSL's list is private to it:

grails:
    plugin:
        springsecurity:
            requestCache:
                ignoredPatterns: ['/favicon.*', '/manifest.json', '/poll/**']

An empty list saves every request, as before. This is on by default, so the upgrade guide lists it. Unlike the DSL's matcher, it does not leave out XHR, JSON, multipart, server-sent event or websocket requests, because the plugin has its own Ajax handling.

The HTTP QUERY method

Spring Framework 7.1 adds HttpMethod.QUERY for RFC 10008, a safe, idempotent request with a body, and FormContentFilter parses its form-encoded bodies. A mapping with method: 'QUERY' already routed, so URL mappings gain the prefix the other methods have:

static mappings = {
    query "/books/search"(controller: 'book', action: 'search')
}
class BookController {

    static allowedMethods = [search: 'QUERY']

    def search(BookSearch criteria) {
        respond Book.findAllByTitleLike("%${criteria.title}%")
    }
}

A JSON or form-encoded body is bound to the command object. GrailsMockHttpServletRequest stands in for FormContentFilter and now parses a form-encoded QUERY body too, so a unit test sees what the running application sees.

  • As for GET, a domain class the action takes is looked up by its identifier but not bound to the body.
  • A browser cannot send QUERY from an HTML form.
  • traceNativeMetadata reaches it with requests; see Native images below.
  • grails-openapi leaves these mappings out and logs each one, because swagger-models and the OpenAPI versions it writes have no operation for the method.
  • The Spring Security plugin accepts QUERY, with its DefaultHttpFirewall. An application using Spring Security's own DSL gets a StrictHttpFirewall, which rejects it until it is added with setAllowedHttpMethods.

RFC 9457 problems for exceptions nothing handles

Spring Framework 7.1 can render any unhandled exception as a problem with ProblemDetailsConfigurer.renderUnhandledExceptions(true). In a Grails application that switch takes every exception of every action ahead of the Grails exception resolver, so a browser gets JSON and "500" URL mappings stop applying. Turned on through ExceptionHandlerExceptionResolver with a Grails action as the handler and a browser's Accept header, it writes {"status":500,"title":"Internal Server Error"} as application/problem+json.

A new setting uses the same translation and keeps the error page for browsers:

grails:
    exceptionresolver:
        problemDetails: true
HTTP/1.1 500
Content-Type: application/problem+json

{"status":500,"title":"Internal Server Error"}
  • ErrorResponse, ResponseStatusException and @ResponseStatus exceptions keep their status and detail, Spring's own exceptions get the status and detail ResponseEntityExceptionHandler gives them, and any other exception is a 500 without a detail, so its message is not exposed.
  • It runs last, after the @ExceptionHandler methods, so it turns on grails.exceptionresolver.springHandlers unless the application sets that.
  • It is written by Spring MVC's handler adapter, so ResponseBodyAdvice and ErrorResponse.Interceptor beans apply, and a server error is recorded on the HTTP server observation.
  • The Grails exception resolver still handles a client that prefers HTML, an exception a URL mapping names, such as "500"(controller: 'errors', action: 'denied', exception: AccessDeniedException) or the "503" mapping of an AsyncRequestTimeoutException, and an action a page includes.

Which clients prefer HTML: a request whose format, from its URL or its Accept header, is html; or, where Grails resolves any format, as for a browser under grails.mime.disable.accept.header.userAgents, one whose first concrete Accept type is HTML. A client that accepts any type, such as curl, gets the problem.

Native images

I built these into a GraalVM 25.2.4 image from end-to-end/native-i18n and ran requests against it, with the happy path traced as the guide describes: respond with a ProblemDetail, an action returning one, unhandled exceptions written as problems, the HTML error page, and QUERY and other methods binding a command object from JSON and form bodies, including an action restricted to QUERY. All answer as on a JVM. That took three fixes:

  • traceNativeMetadata had no effect on the image. It writes to src/native/resources/META-INF/native-image, and nothing put that directory on the image's classpath, so an application that followed the guide still failed on the first request needing what it had recorded. The plugin now adds it. native-i18n checks this in CI: it calls a method only that directory registers.
  • DefaultMimeTypeResolver made a dynamic call in a statically compiled class. Groovy compiles a default argument dynamically, so GrailsWebRequest.lookup() needed reflection on the way to binding a command object or resolving a view. It is now a direct call.
  • HttpMethod is registered, because Groovy's == calls compareTo on it for every method but POST, which a trace that only submits forms never records.

traceNativeMetadata used to send only GET and form POST, so it could not reach an action restricted to QUERY or a JSON body. requests now makes a request as it is declared, after the forms and in their session, and a request with a key it does not know fails the trace:

grails {
    nativeMetadata {
        requests = [
            [method: 'QUERY', path: '/book/search', contentType: 'application/json', body: '{"title": "Grails"}', accept: 'application/json'],
            [method: 'DELETE', path: '/book/1']
        ]
    }
}

A request path still has to be traced: registering what validate() reaches, one hint at a time, only moved the failure to the next call.

A Forge application generated with --data=mongodb --features=spring-boot-starter-security, which secures itself with Spring Security's own DSL and no Grails plugin, builds and runs as an image with its login form, its scaffolded user admin and QUERY traced. In the image, login and logout, a denied page being restored, /manifest.json not being restored, role checks, CSRF, a scaffolded save and the login of the user it saved, and QUERY with JSON and form bodies all answer as on a JVM. As the guide says, QUERY is answered with 400 until StrictHttpFirewall allows it, and needs a token or a CSRF exception. That run found two more bugs:

  • An action with allowedMethods answered a refused method with a 500, not a 405. The check the compiler adds calls response.sendError dynamically, and a trace cannot record it, since it fails on a request that answers with an error. HttpServletResponse is now registered.
  • A trace did not record what a scaffolded save does when it succeeds. The create page renders its fields with value="", which the trace took for a value and sent. Blank fields failed validation, the page answered with the form again, and the trace reported success. In the image, the first real save then stored the row and answered with a 500. A field rendered empty is now filled in. A hidden field is the page's own and is still sent as it is.

Observability

An action that threw answered with a 500 while the observation of the request did not say what failed. With an error view, as in a generated application, http.server.requests tagged it with error=none and exception=none, and the server span ended UNSET. With a "500" mapping to a controller, it tagged it with GrailsWrappedRuntimeException and the uri of that controller, and the span carried the same wrapper and route. Spring Framework 7.1 records the exceptions Spring MVC translates to a 5xx, and these never reach it. So the resolver now records them on the observation, which tells its handlers, so that a tracing handler marks the span, and it keeps the action's route. With a real OpenTelemetry tracer:

In a running application Micrometer conventions OpenTelemetry conventions
An action that throws, 500 error=IllegalStateException, span ERROR error.type=java.lang.IllegalStateException, span ERROR
A missing action, 404 error=none error.type=none

An exception written as a problem is recorded in the same way. After a forward to an error controller, jakarta.servlet.error.exception holds the exception the action threw rather than the wrapper, for a filter that reads it once the chain has returned. After an error view has rendered it is still cleared, by Spring MVC, because Tomcat's ErrorReportValve resets a response that isn't committed when it finds that attribute.

This does not come from Spring Boot 4.2: 8.0.x and 9.0.x report the same values. #16879 fixes it on 8.0.x with the same code, which merges into this branch without conflicts, and it is here too so that 9.1.x does not depend on the order the two land in. In development mode an action also failed with an InvocationTargetException, because it is invoked by reflection, and it now throws its own exception there as well.

An alert that counts a non-none error value sees more server errors than before, and a dashboard that reads the uri of an error controller finds those failures under the routes of the actions, as the upgrade guide says. I measured spans with the OpenTelemetry bridge, not with Brave or a tracing backend.

The guide also covers two changes from Spring Boot 4.2: http.server.requests.active is opt-in, through management.metrics.observations.include-active-long-task-timer, and management.observations.conventions: open-telemetry switches to OpenTelemetry's conventions. Under them http.route is /controller/action in a Grails application, not the URL mapping's pattern.

Documentation of three Spring Boot 4.2 changes

  • X-Forwarded-Prefix is off by default. Spring Boot 4.1 built ForwardedHeaderFilter with the constructor that turns it on. With server.forward-headers-strategy: framework, an application behind a proxy that strips a path prefix loses the prefix from absolute links and redirects until it sets spring.mvc.forwarded-headers.use-forwarded-prefix: true.
  • bootBuildImage defaults to the ubuntu-resolute builder, on Ubuntu 26.04, whose run image the builder no longer pins, and can keep the build cache in an image in a registry.
  • spring-boot-starter-amqp no longer means RabbitMQ. It is now the starter for generic AMQP 1.0, so an application using RabbitMQ through it changes to spring-boot-starter-rabbitmq.

Spring Security plugin

Running the request cache change above in a native image and on every option of the plugin found these, neither of which comes from Spring Boot 4.2.

It builds a native image

processAot failed on the plugin, and an image could not load its defaults, so an application using it had no image. It now traces its login as the guide describes for any login form:

grails {
    nativeMetadata {
        forms = ['/login/auth?username=admin&password=secret']
        paths = ['/', '/book']
    }
}
  • The password encoders, the session listener and the after-invocation provider were live objects in their bean definitions, which ahead-of-time processing cannot write out. They are now beans, which adds passwordEncoders and nullAfterInvocationProvider to the context.
  • passwordEncoder, authenticationManager and springSecurityFilterChainProxy have overlapping constructors it cannot choose between, so the default classes are created by a static factory method. A class configured in their place is still created by its constructor, with what it was given before.
  • DefaultSecurityConfig was compiled from its script at every startup. It is now compiled with the plugin, and a runtime hint keeps the constructor ConfigSlurper calls, which a trace does not see.
  • The plugin answers every request with 503 until the application has started, and a run's log and open port both come before that, so traceNativeMetadata failed on the refusal and trainAotCache trained on it. Both now wait while / answers 503, for no longer than startTimeoutSeconds.

In an image with a traced login, with both InterceptUrlMap and the default Annotation security, the login page renders, a wrong password returns to it with its message, a login after a denied page returns to that page, and a login after /manifest.json, /apple-touch-icon.png or the DevTools path goes to the default page instead. trainAotCache trains against the plugin too. PluginBeansAotSpec runs Spring's ahead-of-time generator over what the plugin declares for each security config type, the optional filters, Digest authentication and configured classes, so a bean it cannot write out fails the module's tests.

Digest authentication starts and signs users in

useDigestAuth with its default of hashed passwords failed at startup, naming a DigestAuthPasswordEncoder that no longer exists. It cannot come back, because PasswordEncoder is no longer given the username the hash needs. With digest.useCleartextPasswords the application started but signed nobody in: the password encoder stored {noop}secret, and the filter hashes what is stored as a browser hashes the password.

By default what a user has stored is now the hash a browser computes, which the application computes:

password = "${username}:${realmName}:${password}".md5()

With digest.useCleartextPasswords, what is stored is what was typed. In both cases passwordEncoder is a NoOpPasswordEncoder, unless a class is configured, because the filter hands the authentication manager what is stored as the credentials. So a form signs a user in with cleartext passwords stored, and cannot with the hash stored. An application that signed in through a form with stored values such as {noop}secret removes the prefix, which the upgrade guide says.

That filter leaves an unauthenticated token, so that the account checks of the authentication manager still run, and the compat FilterSecurityInterceptor went straight to the access decision, so a right password was answered with 403. It now authenticates such a token first, as Spring Security's own interceptor did before Spring Security 7 removed it, and honours fii.alwaysReauthenticate, which did nothing. A disabled account is refused. DigestAuthenticationSpec sends a browser's handshake through the plugin's whole filter chain, and curl --digest against a running application and against an image gets 200 and 401 for a right and a wrong password.

A malformed Content-Type no longer fails a request with a token reader

BearerTokenReader parses the Content-Type of any request without a bearer header, and nothing caught the exception. Spring Framework 7.1 rejects a parameter without a value or a repeated one, so application/x-www-form-urlencoded;foo, which used to be read, failed the request with a 500. It now reads only the type, and a type it cannot read is not a form. Grails' own request handling does not have this problem: a running application answered malformed Accept and Content-Type headers without an error, through both render and respond.

Limitations

  • 9.1.x snapshots depend on release candidates of Spring Boot, Spring Framework, Spring Security, Spring Data, Micrometer and Reactor until 4.2.0 is released, and 9.1.0 cannot be released before it.
  • An unexpected exception written as a problem is logged at ERROR by Spring's ProblemDetailExceptionHandler with its stack trace, which Grails has no way to change, and by Grails with the method and URI, so it takes two log entries. The grails.exceptionresolver settings for what is logged, such as logRequestParameters, apply to what the Grails exception resolver logs, and so not to these.
  • An exception a view throws while it renders is not one an action throws, and goes to the error page for every client.
  • The Spring Security plugin's companions (REST, LDAP, ACL, CAS, OAuth2 and UI) do not build an image yet: each compiles a default configuration script as it starts, which an image cannot do. The guide says so.
  • The login in the image above used a statically compiled UserDetailsService, because the fixture has no GORM. The default GormUserDetailsService has not been run in an image, and the image is not built in CI, where each one would add about three minutes.
  • A Hibernate application does not build an image. With grails-data-hibernate5, the default of a generated application, compileAotJava fails on the code generated for its dataSource (InstanceFactoryBean cannot be converted to DataSource), which stops AOT processing for it altogether. With grails-data-hibernate7 an application is traced and compiled, and the image fails as it starts, because Hibernate's ByteBuddy bytecode provider cannot initialize there. MongoDB is the data layer that runs in an image.

Spring Boot 4.2.0-RC2 manages Jackson 3.1.8 and 2.21.8. No advisory
published since 3.2.3 and 2.22.3 affects either pin, so Jackson 2 stays at
2.22.3, which Groovy YAML 6.0.0 needs, and Jackson 3 stays at 3.2.3, so
that moving to Spring Boot 4.2 does not take it from the 3.2 line back to
3.1.
Spring Boot 4.2.0-RC2 still manages Spring Security 7.2.0-RC1, which the
advisory does not list and which carries the 7.1.1 fix.
…nages 2.6.1

Spring Boot 4.2.0-RC2 manages json-smart 2.6.1, which rejects a literal
0x1A after the value in strict mode (netplex/json-smart-v2#305). That
fixes CVE-2026-113602, so the exclusion's removal condition, a patched
release, is met.
Spring Boot 4.2.0-RC2 manages json-smart 2.6.1, whose accessors-smart
2.6.1 depends on ASM 9.11.1. That wins over the 9.10.1 the BOM managed,
and validateDependencyVersions failed for every project with json-smart on
its classpath, such as the gsp-spring-boot and scaffolding test examples.
Spring Boot 4.2.0-RC2 manages Hibernate ORM 7.4.14.Final. The Hibernate 7
BOM strictly re-declares Spring Boot's version so that enforcedPlatform
consumers are forced to it, and was still at RC1's 7.4.13.Final. 7.4.14
depends on the same hibernate-models 1.1.2, so that pin does not move.
The Hibernate 7 BOM pinned Hibernate ORM in dependencies.gradle, so every
Spring Boot upgrade that moved Hibernate needed a matching manual bump, and
a forgotten one held the BOM back.

grails-hibernate7-bom now reads the version spring-boot-dependencies
manages for hibernate-core and strictly re-declares hibernate-core,
hibernate-envers and hibernate-jcache at it, under Spring Boot's
hibernate.version, so enforcedPlatform consumers are still forced to it.
The published POM is unchanged apart from the order of those entries.

When Spring Boot lags behind a Hibernate ORM 7 release Grails needs,
hibernate7VersionOverride in dependencies.gradle manages that release
instead. The build fails while the override is not newer than Spring
Boot's version, so it cannot silently downgrade Hibernate once Spring Boot
catches up.

hibernate-models is strictly re-declared too, and now moves without anyone
editing the Hibernate block, so the build also fails when
hibernate-models.version differs from the version the managed Hibernate
ORM release depends on.
@codeconsole
codeconsole requested review from jamesfredley, jdaugherty, matrei and sbglasius and removed request for jdaugherty November 5, 2026 19:11
Spring Security 7.2's configuration DSL no longer saves requests for
/favicon.*, /apple-touch-icon*.png, /manifest.json, /manifest.webmanifest,
/browserconfig.xml and Chrome DevTools'
/.well-known/appspecific/com.chrome.devtools.json. The plugin builds its beans
without the DSL and saved every request, so a denied request for one of these
paths became the page a login redirected to.

The default requestMatcher now leaves out the paths the new
requestCache.ignoredPatterns setting names, by default the same list, which is
private to the DSL. A class set with requestMatcherBeanClass is left as it was.
Spring Framework 7.1 adds HttpMethod.QUERY, for RFC 10008, and its
FormContentFilter now parses form-encoded QUERY bodies.

A mapping with method: 'QUERY' already routed, and a body bound as a POST's does,
so URL mappings gain the query prefix they have for the other methods.
GrailsMockHttpServletRequest stands in for FormContentFilter and did not parse a
QUERY body, so a unit test saw different params from the running application.

The app1, enable-mvc-check and Spring Security functional apps send QUERY
requests with JSON and form-encoded bodies, and the guide says what QUERY does
not do: an HTML form cannot send it, OpenAPI has no operation for it, and
Spring Security's own DSL needs it allowed on its firewall.
Spring Framework 7.1 can render any unhandled exception as a problem with
ProblemDetailsConfigurer.renderUnhandledExceptions, but in a Grails application
that switch takes every exception of every action ahead of the Grails exception
resolver, so a browser gets JSON and "500" URL mappings no longer apply.

grails.exceptionresolver.problemDetails, off by default, uses the same
translation for an exception an action throws and no @ExceptionHandler method
handles, written by Spring MVC's handler adapter with its advice and
interceptors. A client that prefers HTML, and an exception a URL mapping names,
still go to the Grails exception resolver.

As Spring MVC does, a server error is recorded on the HTTP server observation,
and a failure to write the problem leaves the exception to the other resolvers.
Spring's translation logs an unexpected exception without its request, so the
resolver logs the method and URI too.
… 4.2

Spring Boot 4.2 builds its ForwardedHeaderFilter with the X-Forwarded-Prefix
header off, where Spring Boot 4.1 used the constructor that turns it on. An
application behind a proxy that strips a path prefix loses the prefix from its
absolute links and redirects until it sets
spring.mvc.forwarded-headers.use-forwarded-prefix.

bootBuildImage now defaults to the ubuntu-resolute builder, on Ubuntu 26.04, whose
run image the builder no longer pins, and can keep the build cache in an image in
a registry.
With grails.exceptionresolver.problemDetails alone, an @ExceptionHandler method
was never consulted, so an action's exception became a generic 500 where
ProblemDetailsConfigurer.renderUnhandledExceptions, which the guide offers it in
place of, would have run the method.

A problem is the last resort after those methods, as it is in Spring MVC, so the
setting now turns on grails.exceptionresolver.springHandlers unless the
application sets that.
- A "503" mapping that names AsyncRequestTimeoutException, which the guide
  documents, was looked for under 500 and passed over. It is looked for under the
  status the Grails exception resolver uses.
- An @ExceptionHandler method that declares what it produces and fails left that
  type on the request, so the problem could not be written. Spring MVC removes it
  for its own last resort, and this does too.
- A client that went away is logged in a category of its own and no response is
  attempted, as in Spring MVC, where the write failure sent a WARN and an error
  page to a dead connection, and the exception was logged as a failed request.
- renderView stayed false when the write failed, so an action answering the error
  page had what it returned dropped. It is set once the problem is written.
- An action a page includes is left to the other resolvers, so that a problem is
  not written into the page.
- The request of a forwarded action is logged by the URI the client asked for, as
  the Grails exception resolver logs it.
ActionReturnValues looked up the method in its static initializer, which every
web application runs, whether it opts into problem details or not. Without a hint
for it a native image would fail to initialize the class, and with it the handler
adapter. It is now looked up where a problem is written, whose failure leaves the
exception to the other resolvers, and UrlMappingRuntimeHints registers it.
… problems

The two browser rows of the functional spec behaved as their neighbours, because
grails.mime.disable.accept.header.userAgents, which makes Grails resolve any format
for a browser, was not set in the test application, so the first concrete Accept
type was never what decided. The application now sets it.

The guide said an exception caused by an ErrorResponse keeps its status, which only
holds for a ResponseStatusException or @ResponseStatus, and that any other exception
is a 500 without a detail, where Spring's own exceptions keep theirs. It also did not
say that the settings for what the Grails exception resolver logs do not apply, or
that an action a page includes is left to that resolver.
The wiring in the plugin had no test beyond the default list: a list an application
sets, an empty list, and a class set with requestMatcherBeanClass, which must not be
given a property it may not have, all go through the real plugin now.

The spec that only pinned how Spring's own matcher treats a parsed request path goes.
… guide says

A QUERY reaches a secured action through the Spring Security plugin's authorization
as a PUT and a PATCH do, and grails-openapi skips a QUERY mapping, which were claimed
but not tested.

The guide said a body binds as a POST's does, which does not hold for a domain class;
that g:form sends a POST, which depends on how the hidden method is resolved; and that
OpenAPI has no operation for QUERY, where this generator writes versions that have
none. FormContentFilter's list of methods is brought up to date where it is repeated.
@codecov

codecov Bot commented Nov 5, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 75.77320% with 47 lines in your changes missing coverage. Please review.
✅ Project coverage is 65.3069%. Comparing base (a53d15f) to head (28dc2dd).
⚠️ Report is 6 commits behind head on 9.1.x.

Files with missing lines Patch % Lines
.../mapping/mvc/ActionExceptionHandlerResolver.groovy 75.0000% 3 Missing and 12 partials ⚠️
...rails/gradle/plugin/core/GrailsGradlePlugin.groovy 14.2857% 12 Missing ⚠️
.../access/intercept/FilterSecurityInterceptor.groovy 0.0000% 9 Missing ⚠️
...g/grails/web/mapping/mvc/ActionReturnValues.groovy 55.5556% 1 Missing and 3 partials ⚠️
...grails/web/mapping/DefaultUrlMappingEvaluator.java 40.0000% 3 Missing ⚠️
...s/gradle/plugin/aot/TraceNativeMetadataTask.groovy 93.3333% 0 Missing and 2 partials ⚠️
.../org/grails/core/DefaultGrailsControllerClass.java 66.6667% 1 Missing ⚠️
.../grails/gradle/plugin/aot/TrainAotCacheTask.groovy 80.0000% 0 Missing and 1 partial ⚠️
Additional details and impacted files

Impacted file tree graph

@@                Coverage Diff                 @@
##                9.1.x     #16871        +/-   ##
==================================================
+ Coverage     65.2517%   65.3069%   +0.0552%     
- Complexity      32202      32339       +137     
==================================================
  Files            2430       2430                
  Lines          120285     120459       +174     
  Branches        22261      22300        +39     
==================================================
+ Hits            78488      78668       +180     
+ Misses          31801      31795         -6     
  Partials         9996       9996                
Files with missing lines Coverage Δ
...grails/controllers/aot/ControllerRuntimeHints.java 100.0000% <ø> (ø)
...lers/GrailsFormContentFilterAutoConfiguration.java 100.0000% <ø> (ø)
...core/src/main/groovy/grails/config/Settings.groovy 100.0000% <ø> (ø)
...re/gorm/mongodb/embedded/InMemoryMongoBackend.java 100.0000% <100.0000%> (ø)
...org/apache/grails/gradle/plugin/aot/RunPort.groovy 85.7143% <100.0000%> (+19.0476%) ⬆️
...org/grails/web/mime/DefaultMimeTypeResolver.groovy 90.0000% <100.0000%> (+27.5000%) ⬆️
...lugins/testing/GrailsMockHttpServletRequest.groovy 24.4240% <100.0000%> (+2.7650%) ⬆️
...ovy/grails/web/servlet/mvc/GrailsParameterMap.java 82.4324% <ø> (ø)
...oovy/org/grails/web/config/http/GrailsFilters.java 100.0000% <ø> (ø)
...org/grails/web/errors/GrailsExceptionResolver.java 82.4561% <100.0000%> (+1.9006%) ⬆️
... and 10 more

... and 9 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

traceNativeMetadata merged what it recorded into
src/native/resources/META-INF/native-image, as the guide says, but nothing put that
directory on the classpath the image is built from, so the recording changed nothing.
An image built after a trace started and failed on the first request that needed what
was recorded, with a MissingReflectionRegistrationError for a call the file listed.

The plugin now adds the classpath entry above META-INF, beside the metadata it
generates, and fails the build with the reason if outputDirectory is somewhere an
image cannot read. native-i18n calls a method only that directory registers, named by
a property because GraalVM resolves a lookup of a constant name while it builds the
image, so CI's image fails if the metadata stops reaching it: without the plugin
change it fails with ClassNotFoundException.
DefaultMimeTypeResolver is statically compiled, but Groovy compiles a default argument
dynamically even so: the methods without a request called GrailsWebRequest.lookup()
through invokedynamic. Binding a command object and resolving a view both go through
them, so in a native image they needed a reflection hint or a trace, and an error page
rendered through a view can never be traced, since the trace fails on an error.

Written out as overloads, they call it directly.
Binding a command object compares the request's HttpMethod with POST. Groovy's ==
calls compareTo on a Comparable unless the two are the same instance, so it reaches
compareTo reflectively for every method but POST, and a trace that only submits forms
never records it. A GET or QUERY then failed in a native image.
paths are asked for with GET and forms are posted as forms, so a trace could not reach
an action restricted to QUERY, or what binds a JSON body or writes a JSON response.
requests makes a request as it is declared, after the forms and in their session:

    requests = [[method: 'QUERY', path: '/book/search', contentType: 'application/json',
                 body: '{"title": "Grails"}', accept: 'application/json']]

One that does not name its method and path, or that names anything else, fails the
trace rather than recording something other than what was meant.
A scaffolded create page renders its fields with value="", which the trace
took for a value and sent. Blank fields failed validation, the page answered
with the form again, and the trace reported success having recorded nothing
of what a save does when it succeeds. In the image, the first real save then
stored the row and answered with a 500.

A field rendered empty is now filled in as one with no value is. A hidden
field is the page's own and is still sent as it is.
BearerTokenReader parses the Content-Type of any request without a bearer
header, and nothing caught the exception. Spring Framework 7.1 rejects a
parameter without a value or a repeated one, so a request sending such a
Content-Type, which used to be read, failed with a 500.

Only the type matters here, so only the type is read, and a type that cannot
be read is not a form.
…s RabbitMQ

Spring Boot 4.2 makes it the starter for generic AMQP 1.0 and gives RabbitMQ
starters of its own. Grails uses neither, but an application using RabbitMQ
through it has to change them.
…r observation

The resolver handled an exception out of sight of the observation of the
request, so an action that threw answered with a 500 while
http.server.requests tagged it with error=none and exception=none, and
http.server.request.duration with error.type=none. Spring Framework 7.1 records
the exceptions Spring MVC translates to a 5xx, but this one never reaches it.

It now records them as Spring MVC does. The spec runs the resolver inside
Spring's own ServerHttpObservationFilter, and fails without this. In a running
application a 500 is tagged with the exception under both Micrometer's and
OpenTelemetry's conventions, and a 404 still with none.
http.server.requests.active is no longer registered unless asked for, which a
dashboard reading it needs to know, and metrics and spans can now use
OpenTelemetry's semantic conventions. Under them http.route is /controller/action
in a Grails application, which is worth saying since it is not the URL mapping's
pattern.
…an error handler

A "500" mapping to a controller forwards to it, which copies the resolver's
model to the request and routes the request again. That left the
GrailsWrappedRuntimeException in jakarta.servlet.error.exception, which
ServerHttpObservationFilter reads last, so http.server.requests reported the
wrapper over the exception the resolver had recorded, and the handler's route
rather than the action's, so every failure a handler answered looked like the
same route. The resolver now removes the attribute after the forward, as Spring
MVC does once it has rendered an error view, and restores the route.

Neither this nor the error=none the previous fix addressed comes from Spring
Boot 4.2: 8.0.x on Boot 4.1.2 and 9.0.x report the same values. An application
that has no error view to render, such as the cache example, hides the gap,
because the failed render leaves the exception on the request for the filter.
The new functional spec runs through a real dispatch for that reason: the unit
spec cannot see what Spring MVC clears after rendering.
In development mode an action is invoked by reflection, so it failed with an
InvocationTargetException, while the method handle used everywhere else throws
the exception itself. The resolver does not unwrap that one, so what reached it,
the action's span and the observation of the request depended on the
environment.
The same fix goes to 8.0.x, on Spring Framework 7.0, so the comment and the spec
no longer compare it with what Spring Framework 7.1 does. The upgrade guide still
does. With the same text on both lines, the merge up does not conflict over it.

@jamesfredley jamesfredley left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Spring Boot 4.2.0-RC2 bump keeps the exception handling, observation recording, and native metadata wiring on the same path. The new ProblemDetailExceptionHandler call matches the managed Spring Framework API.

The previous commit removed jakarta.servlet.error.exception once the forward had
returned, so a filter that reads it after chain.doFilter got null, with or
without an observation, where it used to get a GrailsWrappedRuntimeException. It
now holds the exception the action threw. Holding the wrapper is not an option,
because ServerHttpObservationFilter reads that attribute last and would report
the wrapper again.
Setting the error on the context reaches the metrics, which read it when the
observation stops, but only Observation.error calls the handlers' onError, and
that is how the server tracing handler marks a span. With a real OpenTelemetry
tracer the span of a failed action ended with status UNSET and no exception
event.

The resolver now finds the observation through the registry's scopes and records
the exception on it, once the error page has been resolved. An exception a
forward left on the request is not recorded there, because the filter records
what it finds in that attribute when it stops the observation, and the handlers
would hear of it twice.
ActionExceptionHandlerResolver set the error on the context, as the Grails
exception resolver did, which reaches the metrics but not the handlers' onError,
so a tracing handler did not mark the span of a server error written as a
problem. It now records the exception on the observation in the same way.
@codeconsole

codeconsole commented Nov 6, 2026 •

Copy link
Copy Markdown
Contributor Author

@jamesfredley You approved this at 4c19027, and I've since pushed more commits, so please take another look at them. Your #16879 reviews apply here too, because the two PRs share that code, and GrailsExceptionResolver is identical on both branches.

  • From 8.0 (fix): Report a failed action's exception and route on the HTTP server observation #16879: f4cc7eb keeps jakarta.servlet.error.exception set after a forward to an error handler. 36f4967 and b912584 tell the observation's handlers, through the observation the filter keeps on the request, so a filter with its own registry is covered. d049b77 and 6476509 record an exception an error handler answers on an async dispatch, where the filter doesn't read that attribute, including for an action forwarded to from that dispatch. 34f91c1 keeps the route that failed when an error handler is routed again after async processing started, and 28dc2dd sets it before an interceptor can fail.
  • Only here: 2bf0865 and 829e30b do the same in ActionExceptionHandlerResolver, which is new on 9.1.x. 24ecc02 keeps the status of a wrapped exception written as a problem. 54285c2 closes a gap in the embedded MongoDB warm-up: Netty tracks about one buffer in 128 at random, and a tracked buffer loads classes the warm-up's one request usually doesn't.

With a real OpenTelemetry tracer on RC2, a failed action's span now ends ERROR with the real exception as its one event, and with the action's route, for an error view, an error controller, and an error controller from an async dispatch.

…first client

Netty tracks about one buffer in 128 for leaks, picked at random, and a tracked
buffer loads classes an untracked one does not. The one request the server answers
as it starts rarely gets that far, so each buffer allocated while Spring Boot has
the jars of an executable jar closed still had about one chance in 128 of failing.
EmbeddedMongoLifecycleSpec failed that way in CI, in ResourceLeakDetector.track0.

The server now allocates buffers as it starts until one has been tracked, whatever
the configured level. The spec allocates as many while the jars are closed, so it
fails on every run without this instead of about once in 128.

@jamesfredley jamesfredley left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not approved. A handled failure is recorded on the observation context, but the observation handlers are not told. A tracing handler that listens for onError does not mark the span failed.

…uest

The resolver looked for the observation through the application's registry, so a
filter built with a registry of its own was missed, and the error only reached the
context, which does not call the handlers' onError. A tracing handler then left the
span unmarked. ServerHttpObservationFilter keeps its observation in a request
attribute, whatever registry built it, so the resolver now uses that one.

The attribute is private to the filter, which offers only the context. The specs run
Spring's real filter and fail if the attribute is renamed.
ActionExceptionHandlerResolver had the same gap as the Grails exception resolver: a
filter built with a registry the application does not know was missed, so the error
only reached the context. It now uses the observation the filter keeps on the request.
The resolver left an exception a forward kept on the request to the filter, which
records what it finds in that attribute when it stops the observation. On an
asynchronous dispatch the filter leaves stopping it to a listener that does not look,
so an asynchronous action answered by an error controller left the span unmarked.
The resolver now leaves it to the filter only where the filter will read it.

The functional spec fails actions through Spring's own async handling, for both an
error view and an error controller.
The previous commit went by the type of the dispatch, which is FORWARD for an action
forwarded to from an asynchronous dispatch, so its exception was still left to a filter
that would not read it. The attribute the container sets on an asynchronous dispatch
stays through the forward, and WebUtils.isAsync already reads it for the handler adapter.

@jamesfredley jamesfredley left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not approved. Unwrapping a problem-details failure follows the cause chain and can turn a 410 into a 500. A handled failure now calls Observation.error, including on an asynchronous dispatch. Threads PRRT_kwDOAAfRJ860nXmn and PRRT_kwDOAAfRJ860nXmv resolved.

/** As the Grails exception resolver finds the exception an action threw. */
private static Exception unwrap(Exception ex) {
if (ex instanceof InvokerInvocationException || ex instanceof GrailsMVCException) {
Throwable rootCause = ExceptionUtils.getRootCause(ex)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

An InvokerInvocationException wraps a ResponseStatusException of 410, and that 410 has an IllegalStateException cause. This walk reaches the cause and the response is 500. Stop at the application exception. Keep its status.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 24ecc02. unwrap now takes off only InvokerInvocationException and GrailsMVCException, and stops at the exception the action threw, so that 410 stays a 410 and is written with its own status. A new feature in ActionExceptionProblemDetailsSpec covers a ResponseStatusException with a cause of its own, wrapped by each of them and by both, and fails on the previous code.

Finding the exception an action threw followed the cause chain to its end, so a
ResponseStatusException of 410 with a cause of its own, wrapped by a dynamic invocation,
was written from that cause, as a 500. It now takes off only what Groovy and Grails wrap
the exception in, and stops at the exception the action threw.
The resolver put the failed route back on the observation once its forward to an error
handler returned. When the action had started async processing before it threw, the
error handler was routed again after that, and its route replaced the one that failed.
The handler that sets the route now leaves it alone for a request routed to an error
handler, so the restore is gone and the route is never replaced, whenever the error
handler is routed.
The handler that sets the route ran after every other interceptor of the chain, so a
request that failed in one, such as in a Grails interceptor's before(), never got a route
of its own, and its observation was reported as UNKNOWN. It now runs right after the
WebRequestInterceptors, ahead of the interceptors that can fail.
@testlens-app

testlens-app Bot commented Nov 7, 2026 •

Copy link
Copy Markdown

✅ All tests passed ✅

⚠️ TestLens detected flakiness ⚠️

Test Summary

Tip

Inspect all details with the TestLens CLI: testlens pr 16871

SiteMesh 2 Compatibility / SiteMesh 2 Functional Tests (Java 21, indy=false) > :grails-test-examples-geb:integrationTest

Test Runs Flakiness
PerTestRecordingSpec > the recordings of the previous two tests are different ❌ ✅ 3% 🟡

🏷️ Commit: 28dc2dd
▶️ Tests: 133999 executed
⚪️ Checks: 29/29 completed

Test Failures

PerTestRecordingSpec > the recordings of the previous two tests are different (:grails-test-examples-geb:integrationTest in SiteMesh 2 Compatibility / SiteMesh 2 Functional Tests (Java 21, indy=false) | Attempt 1/2)
Condition not satisfied:

secondRecording.length() > MIN_MEANINGFUL_RECORDING_BYTES
|               |        | |
|               2790     | 5000
|                        false
build/gebContainer/recordings/20261107_175112/PASSED-PerTestRecordingSpec_setup_running_a_second_test_to_create_another_recording-20261107-175439.mp4

	at org.demo.spock.PerTestRecordingSpec.the recordings of the previous two tests are different(PerTestRecordingSpec.groovy:105)

Learn more about TestLens at testlens.app/docs.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: No status

Development

Successfully merging this pull request may close these issues.

2 participants