You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

Spring WebClient偶发传递失效Access Token问题排查求助

问题描述

我正在使用Spring Boot 3.0.4搭配Java 17,WebClient在调用API时偶发传递失效的Access Token。

错误场景如下:每日下午4:00会生成新的Access Token,之后在下午4:45又强制生成了新的Access Token,旧Token随即失效。但4:45之后的API调用中,WebClient仍会使用之前的失效Token。按预期Spring应该会在Access Token无效时自动获取新Token,但该问题偶发出现。

Access Token响应示例

{
    "access_token": "********-*****-****-****-***********",
    "scope": "am_application_scope default",
    "token_type": "Bearer",
    "expires_in": 20138
}

WebClient配置代码

@Slf4j
@Configuration
public class Oauth2WebClientConfig {

    private final Environment env;

    @Autowired
    public Oauth2WebClientConfig(Environment env) {
        this.env = env;
    }
    // == Oauth2 Configuration ==

    // == Oauth2 Configuration ==
    @Bean
    ReactiveClientRegistrationRepository clientRegistration() {
        ClientRegistration clientRegistration = ClientRegistration
                .withRegistrationId("custom")
                .tokenUri(env.getProperty("token-uri"))
                .clientId(env.getProperty("client-id"))
                .clientSecret(env.getProperty("client-secret"))
                .authorizationGrantType(AuthorizationGrantType.CLIENT_CREDENTIALS)
                .scope(env.getProperty("scope"))
                .build();
        return new InMemoryReactiveClientRegistrationRepository(clientRegistration);
    }

    @Bean
    ReactiveOAuth2AuthorizedClientService authorizedClientService() {
        return new InMemoryReactiveOAuth2AuthorizedClientService(clientRegistration());
    }

    // == WebFlux Configuration ==
    @Bean
    WebClient webClient(ReactiveClientRegistrationRepository clientRegistration, ReactiveOAuth2AuthorizedClientService authorizedClientService) {
        ServerOAuth2AuthorizedClientExchangeFilterFunction oauth = new ServerOAuth2AuthorizedClientExchangeFilterFunction(
                new AuthorizedClientServiceReactiveOAuth2AuthorizedClientManager(clientRegistration, authorizedClientService));
        oauth.setDefaultClientRegistrationId("custom");

        return WebClient.builder()
                .filter(oauth)
                .filters(exchangeFilterFunctions -> {
                    exchangeFilterFunctions.add(logRequest());
                    exchangeFilterFunctions.add(logResponse());
                })
                .build();
    }

    // == Log Request ==
    private ExchangeFilterFunction logRequest() {
        return ExchangeFilterFunction.ofRequestProcessor(clientRequest -> {
            StringBuilder sb = new StringBuilder("Request: \n")
                    .append(clientRequest.method())
                    .append(" ")
                    .append(clientRequest.headers())
                    .append(" ")
                    .append(clientRequest.url());
            clientRequest
                    .headers()
                    .forEach((name, values) -> values.forEach(value -> sb
                            .append("\n")
                            .append(name)
                            .append(":")
                            .append(value)));
            log.info(sb.toString());

            return Mono.just(clientRequest);
        });
    }

    // == Log Response ==
    private ExchangeFilterFunction logResponse() {
        return ExchangeFilterFunction.ofResponseProcessor(clientResponse -> {
            StringBuilder sb = new StringBuilder("Response: \n")
                    .append("Status: ")
                    .append(clientResponse.rawStatusCode())
                    .append(" ")
                    .append(clientResponse.headers())
                    .append(" ")
                    .append("Body : ")
                    .append(clientResponse.bodyToMono(String.class));
            clientResponse
                    .headers()
                    .asHttpHeaders()
                    .forEach((key, value1) -> value1.forEach(value -> sb
                            .append("\n")
                            .append(key)
                            .append(":")
                            .append(value)));
            log.info(sb.toString());
            return Mono.just(clientResponse);
        });
    }
    // == WebFlux Configuration ==
}

WebClient调用代码

String response = Objects.requireNonNull(webClient.get()
                        .uri(reqUrl)
                        .header("TRACE_ID", traceId)
                        .retrieve())
                .onStatus(HttpStatus::isError, resp -> resp.bodyToMono(String.class).flatMap(error -> Mono.error(new RuntimeException(error))))
                .bodyToMono(String.class)
                .retry(2)
                .doOnError(error -> log.error("API error has occurred {} : TRACE_ID : {}", error.getMessage(), traceId))
                .block();
        log.info("response {} : TRACE_ID : {}", response, traceId);
问题分析与解决思路

核心原因

  1. 授权客户端缓存未感知强制失效:AuthorizedClientServiceReactiveOAuth2AuthorizedClientManager默认仅在Token临近过期(默认提前60秒)或收到API返回的401/403错误时,才会触发Token刷新。但服务端在4:45强制失效旧Token时,客户端缓存的Token还未到过期时间,管理器不会主动刷新,导致继续使用失效Token。

  2. 错误处理逻辑阻断Token刷新触发:当前调用代码中,onStatus将所有错误响应包装为RuntimeException,并直接执行retry(2)。这种处理方式跳过了OAuth2过滤器对401错误的识别,重试时只会重复使用旧Token,不会触发刷新逻辑。

  3. 日志过滤器消耗响应体:logResponse中直接调用clientResponse.bodyToMono(String.class)会消耗响应体,导致后续的错误处理(包括OAuth2过滤器的Token失效判断)无法读取响应内容,进一步阻断刷新逻辑。

解决方法

1. 配置Token提前刷新时间

给授权客户端管理器设置clockSkew,提前刷新Token,避免服务端强制失效时客户端仍使用旧Token:

AuthorizedClientServiceReactiveOAuth2AuthorizedClientManager manager = new AuthorizedClientServiceReactiveOAuth2AuthorizedClientManager(clientRegistration, authorizedClientService);
manager.setClockSkew(Duration.ofMinutes(10)); // 提前10分钟刷新Token

2. 调整错误处理逻辑,让OAuth2过滤器感知失效

修改onStatus处理,仅在非401错误时抛出异常,保留401错误让OAuth2过滤器处理:

.onStatus(status -> status.isError() && status != HttpStatus.UNAUTHORIZED, 
          resp -> resp.bodyToMono(String.class).flatMap(error -> Mono.error(new RuntimeException(error))))

同时将retry(2)替换为retryWhen,仅在Token刷新成功后重试:

.retryWhen(Retry.backoff(2, Duration.ofSeconds(1))
                .filter(e -> e instanceof OAuth2AuthorizationException))

3. 修复日志过滤器的响应体消耗问题

缓存响应体后再进行日志输出,确保后续处理能正常读取:

private ExchangeFilterFunction logResponse() {
    return ExchangeFilterFunction.ofResponseProcessor(clientResponse -> {
        return clientResponse.bodyToMono(String.class)
                .cache()
                .doOnNext(body -> {
                    StringBuilder sb = new StringBuilder("Response: \n")
                            .append("Status: ").append(clientResponse.rawStatusCode())
                            .append("\nHeaders: ").append(clientResponse.headers().asHttpHeaders())
                            .append("\nBody : ").append(body);
                    log.info(sb.toString());
                })
                .map(body -> clientResponse.mutate().body(Mono.just(body)).build());
    });
}

4. 强制刷新Token机制(可选)

如果服务端有Token强制失效的通知渠道,收到通知时调用ReactiveOAuth2AuthorizedClientService.removeAuthorizedClient移除旧授权信息,下一次调用会自动获取新Token:

authorizedClientService.removeAuthorizedClient("custom", OAuth2AuthorizedClientService.OAUTH2_CLIENT_AUTHORIZATION_CONTEXT_CLIENT_ID_ATTRIBUTE_NAME);

内容的提问来源于stack exchange,提问作者praneeth

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.07.28 08:47:47