RestEasyReactive捕获AuthenticationFailedException时响应已写入问题排查
问题背景
搭建了一个简单的Quarkus Reactive Resteasy服务器,核心端点代码如下:
@Path("/") @ApplicationScoped public class Hello { @GET @Path("hello") @RolesAllowed({"roleYouNeed"}) public Uni<Response> hello(){ return Uni.createFrom().item(Response.ok("hello").build()); } }
为统一错误响应格式,创建了多个ExceptionMapper,其中处理未提供JWT令牌场景的UnauthorizedExceptionMapper运行正常:
@Provider//register as JAXRS provider @Priority(1)//override the build-int mapper(which has 5001 prio) @Slf4j public class UnauthorizedExceptionMapper implements ExceptionMapper<UnauthorizedException> { @Override public Response toResponse(UnauthorizedException exception) { log.warn("There was an incoming request that did not have the correct Authorization"); return Response.status(Response.Status.UNAUTHORIZED) .entity(new ErrorResponseObject().addErrorsItem(new ErrorResponseObjectErrorsInner() .code(String.valueOf(Response.Status.UNAUTHORIZED.getStatusCode())) .detail("Unauthorized"))) .build(); } }
但当用户发送无效JWT令牌时,会触发JWTParseException进而抛出AuthenticationFailedException,为此创建了对应的AuthenticationExceptionMapper:
@Provider @Priority(1) @Slf4j public class AuthenticationExceptionMapper implements ExceptionMapper<AuthenticationFailedException> { @Override public Response toResponse(AuthenticationFailedException exception) { log.warn("There was an incoming request that did not have the correct Authorization"); return Response.status(Response.Status.UNAUTHORIZED) .entity(new ErrorResponseObject().addErrorsItem(new ErrorResponseObjectErrorsInner() .code(String.valueOf(Response.Status.UNAUTHORIZED.getStatusCode())) .detail("Unauthorized"))) .build(); } }
该Mapper确实捕获了异常并输出日志:
[com.ene.odp.exc.AuthenticationExceptionMapper] (vert.x-eventloop-thread-0) There was an incoming request that did not have the correct Authorization
但同时出现响应已写入的错误:
2022-07-25 16:22:03,812 ERROR [org.jbo.res.rea.com.cor.AbstractResteasyReactiveContext] (vert.x-eventloop-thread-1) Request failed: java.io.IOException: java.io.IOException: Failed to write at org.jboss.resteasy.reactive.server.vertx.ResteasyReactiveOutputStream.close(ResteasyReactiveOutputStream.java:272) at io.quarkus.resteasy.reactive.jackson.runtime.serialisers.BasicServerJacksonMessageBodyWriter.writeResponse(BasicServerJacksonMessageBodyWriter.java:44) at org.jboss.resteasy.reactive.server.core.ServerSerialisers.invokeWriter(ServerSerialisers.java:207) at org.jboss.resteasy.reactive.server.core.serialization.DynamicEntityWriter.write(DynamicEntityWriter.java:104) at org.jboss.resteasy.reactive.server.handlers.ResponseWriterHandler.handle(ResponseWriterHandler.java:31) at org.jboss.resteasy.reactive.server.handlers.ResponseWriterHandler.handle(ResponseWriterHandler.java:15) at org.jboss.resteasy.reactive.common.core.AbstractResteasyReactiveContext.run(AbstractResteasyReactiveContext.java:141) at org.jboss.resteasy.reactive.server.vertx.VertxResteasyReactiveRequestContext$1$1.handle(VertxResteasyReactiveRequestContext.java:73) at org.jboss.resteasy.reactive.server.vertx.VertxResteasyReactiveRequestContext$1$1.handle(VertxResteasyReactiveRequestContext.java:70) at io.vertx.core.impl.AbstractContext.dispatch(AbstractContext.java:100) at io.vertx.core.impl.AbstractContext.dispatch(AbstractContext.java:63) at io.vertx.core.impl.EventLoopContext.lambda$runOnContext$0(EventLoopContext.java:38) at io.netty.util.concurrent.AbstractEventExecutor.safeExecute(AbstractEventExecutor.java:164) at io.netty.util.concurrent.SingleThreadEventExecutor.runAllTasks(SingleThreadEventExecutor.java:469) at io.netty.channel.nio.NioEventLoop.run(NioEventLoop.java:503) at io.netty.util.concurrent.SingleThreadEventExecutor$4.run(SingleThreadEventExecutor.java:986) at io.netty.util.internal.ThreadExecutorMap$2.run(ThreadExecutorMap.java:74) at io.netty.util.concurrent.FastThreadLocalRunnable.run(FastThreadLocalRunnable.java:30) at java.base/java.lang.Thread.run(Thread.java:833) Caused by: java.io.IOException: Failed to write at org.jboss.resteasy.reactive.server.vertx.ResteasyReactiveOutputStream.write(ResteasyReactiveOutputStream.java:109) at org.jboss.resteasy.reactive.server.vertx.ResteasyReactiveOutputStream.writeBlocking(ResteasyReactiveOutputStream.java:224) at org.jboss.resteasy.reactive.server.vertx.ResteasyReactiveOutputStream.close(ResteasyReactiveOutputStream.java:270) ... 18 more Caused by: java.lang.IllegalStateException: Response has already been written at io.vertx.core.http.impl.Http1xServerResponse.checkValid(Http1xServerResponse.java:675) at io.vertx.core.http.impl.Http1xServerResponse.writeQueueFull(Http1xServerResponse.java:274) at io.quarkus.vertx.http.runtime.filters.AbstractResponseWrapper.writeQueueFull(AbstractResponseWrapper.java:486) at org.jboss.resteasy.reactive.server.vertx.ResteasyReactiveOutputStream.awaitWriteable(ResteasyReactiveOutputStream.java:116) at org.jboss.resteasy.reactive.server.vertx.ResteasyReactiveOutputStream.write(ResteasyReactiveOutputStream.java:82)
疑问:为何捕获该异常时响应已被返回?如何确保仅返回AuthenticationExceptionMapper中定义的响应?
问题原因
Quarkus的SmallRye JWT扩展在处理无效JWT令牌时,会先于自定义ExceptionMapper尝试写入默认的401响应到输出流。当自定义Mapper捕获到AuthenticationFailedException并尝试写入自定义响应时,输出流已经被占用,从而触发"Response has already been written"错误。
解决方案
方案1:用Quarkus配置直接自定义JWT错误响应
无需编写ExceptionMapper,直接在application.properties中配置JWT相关错误响应:
# 设置错误状态码 quarkus.smallrye-jwt.auth-mechanism-error-code=401 # 设置错误提示信息 quarkus.smallrye-jwt.auth-mechanism-error-message=Invalid or expired JWT token # 若需要自定义响应体格式,可结合Quarkus错误处理扩展指定默认响应实体 quarkus.resteasy-reactive.error-handling.default-entity=ErrorResponseObject
方案2:调整Mapper优先级并清除已有响应
如果必须使用自定义ExceptionMapper,可将优先级设为比内部处理逻辑更高(比如@Priority(0)),同时在方法中清除已有响应状态:
@Provider @Priority(0) @Slf4j public class AuthenticationExceptionMapper implements ExceptionMapper<AuthenticationFailedException> { @Context private HttpServletRequest request; @Override public Response toResponse(AuthenticationFailedException exception) { log.warn("Invalid JWT token received"); // 清除已写入的响应状态 request.setAttribute("javax.servlet.error.status_code", null); return Response.status(Response.Status.UNAUTHORIZED) .entity(new ErrorResponseObject().addErrorsItem(new ErrorResponseObjectErrorsInner() .code("401") .detail("Invalid or expired JWT token"))) .build(); } }
方案3:实现ReactiveExceptionMapper适配响应式模型
由于使用的是Reactive Resteasy,推荐实现ReactiveExceptionMapper而非传统ExceptionMapper,它更适配响应式模型,能避免响应重复写入问题:
@Provider @Priority(1) @Slf4j public class AuthenticationExceptionMapper implements ReactiveExceptionMapper<AuthenticationFailedException> { @Override public Uni<Response> toResponse(AuthenticationFailedException exception) { log.warn("Invalid JWT token received"); return Uni.createFrom().item(Response.status(Response.Status.UNAUTHORIZED) .entity(new ErrorResponseObject().addErrorsItem(new ErrorResponseObjectErrorsInner() .code("401") .detail("Invalid or expired JWT token"))) .build()); } }
内容的提问来源于stack exchange,提问作者Marc

