Setting a Request Timeout for a Spring REST API – 为Spring REST API设置请求超时

最后修改: 2021年 1月 25日

中文/混合/英文(键盘快捷键:t)

1. Overview

1.概述

In this tutorial, we’ll explore a few possible ways to implement request timeouts for a Spring REST API.

在本教程中,我们将探讨为Spring REST API实现请求超时的几种可能方式。

Then we’ll discuss the benefits and drawbacks of each. Request timeouts are useful for preventing a poor user experience, especially if there’s an alternative that we can default to when a resource is taking too long. This design pattern is called the Circuit Breaker pattern, but we won’t elaborate more on that here.

然后我们将讨论每一种的好处和坏处。请求超时对于防止糟糕的用户体验非常有用,尤其是当资源耗时过长时,我们可以默认为有一个替代方案。这种设计模式被称为Circuit Breaker模式,但我们不会在这里作更多阐述。

2. @Transactional Timeouts

2.@Transactional超时

One way we can implement a request timeout on database calls is to take advantage of Spring’s @Transactional annotation. It has a timeout property that we can set. The default value for this property is -1, which is equivalent to not having any timeout at all. For external configuration of the timeout value, we must use a different property, timeoutString, instead.

我们可以在数据库调用中实现请求超时的一种方法是利用Spring的@Transactional注解。它有一个timeout属性,我们可以设置。这个属性的默认值是-1,这相当于完全没有任何超时。对于超时值的外部配置,我们必须使用一个不同的属性,timeoutString,来代替。

For example, let’s assume we set this timeout to 30. If the execution time of the annotated method exceeds this number of seconds, an exception will be thrown. This might be useful for rolling back long-running database queries.

例如,让我们假设我们将这个超时设置为30。如果被注释的方法的执行时间超过了这个秒数,就会抛出一个异常。这对于回滚长期运行的数据库查询可能很有用。

To see this in action, we’ll write a very simple JPA repository layer that will represent an external service that takes too long to complete and causes a timeout to occur. This JpaRepository extension has a time-costly method in it:

为了看到这一点,我们将编写一个非常简单的JPA存储库层,它将代表一个外部服务,该服务需要太长的时间来完成,并导致超时发生。这个JpaRepository扩展有一个时间成本高的方法。

public interface BookRepository extends JpaRepository<Book, String> {

    default int wasteTime() {
        Stopwatch watch = Stopwatch.createStarted();

        // delay for 2 seconds
        while (watch.elapsed(SECONDS) < 2) {
          int i = Integer.MIN_VALUE;
          while (i < Integer.MAX_VALUE) {
              i++;
          }
        }
    }
}

If we invoke our wasteTime() method while inside a transaction with a timeout of 1 second, the timeout will elapse before the method finishes executing:

如果我们在一个超时为1秒的事务中调用我们的wasteTime()方法,超时将在该方法完成执行之前过去。

@GetMapping("/author/transactional")
@Transactional(timeout = 1)
public String getWithTransactionTimeout(@RequestParam String title) {
    bookRepository.wasteTime();
    return bookRepository.findById(title)
      .map(Book::getAuthor)
      .orElse("No book found for this title.");
}

Calling this endpoint results in a 500 HTTP error, which we can transform into a more meaningful response. It also requires very little setup to implement.

调用这个端点的结果是一个500的HTTP错误,我们可以将其转化为一个更有意义的响应。它还需要很少的设置来实现。

However, there are a few drawbacks to this timeout solution.

然而,这种超时解决方案也有一些缺点。

First, it’s dependent on having a database with Spring-managed transactions. Second, it’s not globally applicable to a project, since the annotation must be present on each method or class that needs it. It also doesn’t allow sub-second precision. Finally, it doesn’t cut the request short when the timeout is reached, so the requesting entity still has to wait the full amount of time.

首先,它依赖于拥有Spring管理的事务的数据库。其次,它不是全局适用于一个项目,因为注释必须存在于每个需要它的方法或类上。它也不允许有亚秒级的精度。最后,当达到超时时,它不会缩短请求,所以请求的实体仍然需要等待全部时间。

Let’s consider some alternate options.

让我们考虑一些其他的选择。

3. Resilience4j TimeLimiter

3.Resilience4jTimeLimiter

Resilience4j is a library that primarily manages fault-tolerance for remote communications. Its TimeLimiter module is what we’re interested in here.

Resilience4j是一个库,主要管理远程通信的容错。TimeLimiter模块是我们在这里所感兴趣的内容。

First, we must include the resilience4j-timelimiter dependency in our project:

首先,我们必须在项目中包含resilience4j-timelimiter依赖项

<dependency>
    <groupId>io.github.resilience4j</groupId>
    <artifactId>resilience4j-timelimiter</artifactId>
    <version>1.6.1</version>
</dependency>

Next, we’ll define a simple TimeLimiter that has a timeout duration of 500 milliseconds:

接下来,我们将定义一个简单的TimeLimiter,其超时时间为500毫秒。

private TimeLimiter ourTimeLimiter = TimeLimiter.of(TimeLimiterConfig.custom()
  .timeoutDuration(Duration.ofMillis(500)).build());

We can easily configure this externally.

我们可以很容易地在外部配置这个。

We can use our TimeLimiter to wrap the same logic that our @Transactional example used:

我们可以使用我们的TimeLimiter来包装我们的@Transactional例子所使用的相同逻辑。

@GetMapping("/author/resilience4j")
public Callable<String> getWithResilience4jTimeLimiter(@RequestParam String title) {
    return TimeLimiter.decorateFutureSupplier(ourTimeLimiter, () ->
      CompletableFuture.supplyAsync(() -> {
        bookRepository.wasteTime();
        return bookRepository.findById(title)
          .map(Book::getAuthor)
          .orElse("No book found for this title.");
    }));
}

The TimeLimiter offers several benefits over the @Transactional solution. Namely, it supports sub-second precision and immediate notification of the timeout response. However, we still have to manually include it in all endpoints that require a timeout. It also requires some lengthy wrapping code, and the error it produces is still a generic 500 HTTP error. Finally, it requires returning a Callable<String> instead of a raw String.

@Transactional解决方案相比,TimeLimiter提供了几个好处。也就是说,它支持亚秒级精度和超时响应的即时通知。然而,我们仍然必须在所有需要超时的端点中手动包含它。它还需要一些冗长的包装代码,而且它产生的错误仍然是一个通用的500 HTTP错误。最后,它需要返回一个Callable<String>,而不是一个原始String。

The TimeLimiter comprises only a subset of features from Resilience4j, and interfaces nicely with a Circuit Breaker pattern.

TimeLimiter只包括Resilience4j中的一个子集,并与Circuit Breaker模式很好地接口。

4. Spring MVC request-timeout

4.Spring MVCrequest-timeout

Spring provides us with a property called spring.mvc.async.request-timeout. This property allows us to define a request timeout with millisecond precision.

Spring为我们提供了一个名为spring.mvc.async.request-timeout的属性。这个属性允许我们定义一个精确到毫秒的请求超时。

Let’s define the property with a 750-millisecond timeout:

让我们用750毫秒的超时来定义这个属性。

spring.mvc.async.request-timeout=750

This property is global and externally configurable, but like the TimeLimiter solution, it only applies to endpoints that return a Callable. Let’s define an endpoint that’s similar to the TimeLimiter example, but without needing to wrap the logic in Futures, or supplying a TimeLimiter:

该属性是全局的,可从外部配置,但与TimeLimiter解决方案一样,它仅适用于返回Callable的端点。让我们定义一个与TimeLimiter示例类似的端点,但不需要用Futures来包装逻辑,也不需要提供TimeLimiter

@GetMapping("/author/mvc-request-timeout")
public Callable<String> getWithMvcRequestTimeout(@RequestParam String title) {
    return () -> {
        bookRepository.wasteTime();
        return bookRepository.findById(title)
          .map(Book::getAuthor)
          .orElse("No book found for this title.");
    };
}

We can see that the code is less verbose, and that Spring automatically implements the configuration when we define the application property. Once the timeout has been reached, the response is returned immediately, and it even returns a more descriptive 503 HTTP error instead of a generic 500. Every endpoint in our project will inherit this timeout configuration automatically.

我们可以看到,代码不再那么冗长,而且当我们定义应用程序属性时,Spring自动实现了配置。一旦达到超时,就会立即返回响应,它甚至会返回一个更具描述性的503 HTTP错误,而不是通用的500。我们项目中的每个端点都将自动继承这个超时配置。

Now let’s consider another option that will allow us to define timeouts with a little more granularity.

现在让我们考虑另一个选项,它将使我们能够以更大的粒度定义超时。

5. WebClient Timeouts

5.WebClient超时

Rather than setting a timeout for an entire endpoint, we may want to simply have a timeout for a single external call. WebClient is Spring’s reactive web client that allows us to configure a response timeout.

与其为整个端点设置超时,我们可能只想为单个外部调用设置超时。 WebClient是 Spring 的反应式 Web 客户端,它允许我们配置响应超时。

It’s also possible to configure timeouts on Spring’s older RestTemplate object; however, most developers now prefer WebClient over RestTemplate.

也可以在Spring较早的RestTemplate对象上配置超时;但是,大多数开发人员现在更喜欢WebClient而不是RestTemplate

To use WebClient, we must first add Spring’s WebFlux dependency to our project:

要使用WebClient,我们必须首先将Spring的WebFlux依赖项添加到我们的项目中。

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-webflux</artifactId>
    <version>2.4.2</version>
</dependency>

Let’s define a WebClient with a response timeout of 250 milliseconds that we can use to call ourselves via localhost in its base URL:

让我们定义一个WebClient,其响应超时为250毫秒,我们可以通过其基本URL中的localhost调用自己。

@Bean
public WebClient webClient() {
    return WebClient.builder()
      .baseUrl("http://localhost:8080")
      .clientConnector(new ReactorClientHttpConnector(
        HttpClient.create().responseTimeout(Duration.ofMillis(250))
      ))
      .build();
}

Clearly, we can easily configure this timeout value externally. We can also configure the base URL externally, as well as several other optional properties.

很明显,我们可以很容易地在外部配置这个超时值。我们还可以从外部配置基本的URL,以及其他几个可选属性。

Now we can inject our WebClient into our controller, and use it to call our own /transactional endpoint, which still has a timeout of 1 second. Since we configured our WebClient to timeout in 250 milliseconds, we should see it fail much faster than 1 second.

现在我们可以将WebClient注入控制器,并使用它来调用我们自己的/transactional端点,它的超时时间仍然是1秒。由于我们将WebClient配置为在250毫秒内超时,我们应该看到它比1秒更快地失败。

Here is our new endpoint:

这里是我们的新端点。

@GetMapping("/author/webclient")
public String getWithWebClient(@RequestParam String title) {
    return webClient.get()
      .uri(uriBuilder -> uriBuilder
        .path("/author/transactional")
        .queryParam("title", title)
        .build())
      .retrieve()
      .bodyToMono(String.class)
      .block();
}

After calling this endpoint, we can see that we do receive the WebClient‘s timeout in the form of a 500 HTTP error response. We can also check the logs to see the downstream @Transactional timeout, but its timeout will be printed remotely if we called an external service instead of localhost.

调用这个端点后,我们可以看到我们确实收到了WebClient的超时,形式是500 HTTP错误响应。我们也可以检查日志,看看下游的@Transactional超时,但如果我们调用外部服务而不是localhost,其超时将被远程打印。

Configuring different request timeouts for different backend services may be necessary, and is possible with this solution. Also, the Mono or Flux response that publishers returned by WebClient contain plenty of error handling methods for handling the generic timeout error response.

为不同的后端服务配置不同的请求超时可能是必要的,而且在这个解决方案中是可以实现的。此外,由WebClient返回的MonoFlux响应包含大量的错误处理方法,用于处理通用超时错误响应。

6. Conclusion

6.结语

In this article, we explored several different solutions for implementing a request timeout. There are several factors to consider when deciding which one to use.

在这篇文章中,我们探讨了实现请求超时的几种不同解决方案。在决定使用哪种方案时,有几个因素需要考虑。

If we want to place a timeout on our database requests, we might want to use Spring’s @Transactional method and its timeout property. If we’re trying to integrate with a broader Circuit Breaker pattern, using Resilience4j’s TimeLimiter would make sense. Using the Spring MVC request-timeout property is best for setting a global timeout for all requests, but we can also easily define more granular timeouts per resource with WebClient.

如果我们想给数据库请求设置超时,我们可能想使用Spring的@Transactional方法及其timeout属性。如果我们试图与更广泛的Circuit Breaker模式集成,使用Resilience4j的TimeLimiter将是有意义的。使用Spring MVC的request-timeout属性最适合为所有请求设置全局超时,但我们也可以用WebClient轻松地为每个资源定义更细化的超时。

For a working example of all of these solutions, the code is ready and runnable out of the box over on GitHub.

对于所有这些解决方案的工作实例,代码已经准备就绪,可以开箱运行在GitHub上