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

如何实现RetryTemplate重试RestTemplate时复用同一请求ID?

问题:RetryTemplate重试时RestTemplate重复生成请求ID的解决方案

我们有一个用于修改数据的HTTP PUT接口,请求方偶尔会因未收到响应而重试,但服务端可能已处理成功,导致重复修改数据。常规解决方案是在请求头携带固定请求ID,同一请求的所有重试使用同一个ID,服务端据此过滤重复请求。

当前遇到的问题是:RetryTemplate重试时会重新调用RestTemplate,每次重试都会生成不同的请求ID。现有RestTemplate配置如下:

@Bean
public RestTemplate defaultRestTemplate(HttpComponentsClientHttpRequestFactory defaultHttpClientRequestFactory) {
    return new RestTemplateBuilder()
            .requestFactory(() -> defaultHttpClientRequestFactory)
            .additionalRequestCustomizers(
                    request -> request.getHeaders().set(RequestConstants.HEADER_CLIENT_REQUEST_ID, UUID.randomUUID().toString())
            )
            .build();
}

这是RetryTemplate的设计特性,我们需要找到**符合DRY原则(不编写自定义包装方法)**的实现方式,确保以下测试用例能够通过:

@SpringBootTest
public class DefaultHttpRetryTemplateConfigurationTest {

    @Autowired
    protected RestTemplate defaultRestTemplate;

    @Autowired
    protected RetryTemplate defaultHttpRetryTemplate;

    @Test
    void defaultHttpRetryTemplate_providesSameRequestIdInRetryRequests_butDifferentRequestIdsInOriginalRequests() throws IOException, InterruptedException, ExecutionException {
        String[] requestIds = new String[2];
        Future<String> future = Executors.newSingleThreadExecutor().submit(() -> {
            return testRequest();
        });
        requestIds[0] = testRequest();
        requestIds[1] = future.get();
        Assert.assertNotEquals(requestIds[0], requestIds[1]); // 不同的原始请求使用不同的请求ID
    }

    private String testRequest() throws IOException, InterruptedException {
        try (MockWebServer mockWebServer = new MockWebServer()) {
            mockWebServer.enqueue(new MockResponse().setResponseCode(500));
            mockWebServer.enqueue(new MockResponse().setResponseCode(200));
            mockWebServer.start();

            HttpUrl url = mockWebServer.url("/test");

            HttpEntity<String> entity = new HttpEntity<>("{}");
            this.defaultHttpRetryTemplate.execute(retryCallback -> {
                return this.defaultRestTemplate.exchange(url.uri(), HttpMethod.PUT, entity, String.class);
            });

            RecordedRequest[] requests = new RecordedRequest[2];
            requests[0] = mockWebServer.takeRequest();
            requests[1] = mockWebServer.takeRequest();

            String[] uuids = new String[2];
            uuids[0] = requests[0].getHeader(RequestConstants.HEADER_CLIENT_REQUEST_ID);
            uuids[1] = requests[1].getHeader(RequestConstants.HEADER_CLIENT_REQUEST_ID);

            Assert.assertEquals(uuids[0], uuids[1]); // 同一请求的重试使用相同的请求ID
            return uuids[0];
        }
    }
}

解决方案

利用ThreadLocal存储请求ID,结合Spring Retry的RetryListener和RestTemplate的自定义配置,实现同一重试周期内复用请求ID、不同请求隔离ID的效果,完全基于Spring扩展点实现,无需自定义包装方法:

1. 定义请求ID持有类(ThreadLocal存储)

public class RequestIdHolder {
    private static final ThreadLocal<String> REQUEST_ID = new ThreadLocal<>();

    public static void setRequestId(String requestId) {
        REQUEST_ID.set(requestId);
    }

    public static String getRequestId() {
        return REQUEST_ID.get();
    }

    public static void clear() {
        REQUEST_ID.remove();
    }
}

2. 修改RestTemplate配置,从ThreadLocal获取请求ID

@Bean
public RestTemplate defaultRestTemplate(HttpComponentsClientHttpRequestFactory defaultHttpClientRequestFactory) {
    return new RestTemplateBuilder()
            .requestFactory(() -> defaultHttpClientRequestFactory)
            .additionalRequestCustomizers(
                    request -> {
                        String requestId = RequestIdHolder.getRequestId();
                        // 仅当ThreadLocal中无ID时才生成新ID
                        if (requestId == null) {
                            requestId = UUID.randomUUID().toString();
                            RequestIdHolder.setRequestId(requestId);
                        }
                        request.getHeaders().set(RequestConstants.HEADER_CLIENT_REQUEST_ID, requestId);
                    }
            )
            .build();
}

3. 配置RetryTemplate,通过RetryListener管理请求ID生命周期

@Bean
public RetryTemplate defaultHttpRetryTemplate() {
    RetryTemplate retryTemplate = new RetryTemplate();
    
    // 配置重试策略(根据你的需求调整,示例为最多重试2次)
    SimpleRetryPolicy retryPolicy = new SimpleRetryPolicy();
    retryPolicy.setMaxAttempts(2);
    retryTemplate.setRetryPolicy(retryPolicy);

    // 添加RetryListener,在重试周期开始时生成ID,结束时清理ThreadLocal
    retryTemplate.registerListener(new RetryListener() {
        @Override
        public <T, E extends Throwable> boolean open(RetryContext context, RetryCallback<T, E> callback) {
            // 首次执行时生成请求ID
            if (RequestIdHolder.getRequestId() == null) {
                RequestIdHolder.setRequestId(UUID.randomUUID().toString());
            }
            return true;
        }

        @Override
        public <T, E extends Throwable> void close(RetryContext context, RetryCallback<T, E> callback, Throwable throwable) {
            // 重试结束后清理ThreadLocal,避免内存泄漏
            RequestIdHolder.clear();
        }

        @Override
        public <T, E extends Throwable> void onError(RetryContext context, RetryCallback<T, E> callback, Throwable throwable) {
            // 重试出错时保持ID不变,无需额外操作
        }
    });

    return retryTemplate;
}

方案说明

  • 同一RetryTemplate执行周期内,RetryListener的open方法会生成一次请求ID并存入ThreadLocal,后续重试时RestTemplate会直接从ThreadLocal获取该ID,不会重新生成。
  • 不同的原始请求(如多线程场景),ThreadLocal是线程隔离的,因此会生成不同的请求ID,满足测试用例的要求。
  • 完全基于Spring提供的扩展点实现,没有编写自定义包装方法,符合DRY原则。

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

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.07.01 12:57:02