Backend Development 10 min read

Master API Timeout in Spring Boot 3 with Custom @Timeout Annotation and AOP

This article demonstrates how to solve Spring Boot API timeout issues by creating a custom @Timeout annotation combined with an AOP aspect, covering annotation definition, aspect implementation, executor handling, fallback logic, configuration, and practical test results.

Spring Full-Stack Practical Cases
Spring Full-Stack Practical Cases
Spring Full-Stack Practical Cases
Master API Timeout in Spring Boot 3 with Custom @Timeout Annotation and AOP

Environment: Spring Boot 3.4.2

1. Introduction

API timeout is a common performance and stability problem in Spring Boot applications. By using a custom annotation together with an AOP aspect, developers can configure timeout policies without writing repetitive code, improving maintainability and extensibility.

2. Practical Example

2.1 Custom Annotation

<code>@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Timeout {
    // Base timeout (supports SpEL, e.g., "${pack.app.xxx.timeout}")
    String value() default "5000";
    // Time unit
    TimeUnit unit() default TimeUnit.MILLISECONDS;
    // Retry count (default no retry)
    int retry() default 0;
    // Retry interval (ms)
    long retryDelay() default 0;
    // Fallback method name (must be in the same class)
    String fallback() default "";
    // Executor bean name for thread pool
    String executor() default "timeoutExecutor";
}</code>

2.2 Aspect Definition

<code>@Aspect
@Component
public class TimeoutAspect implements BeanFactoryAware {
    private static final Logger logger = LoggerFactory.getLogger(TimeoutAspect.class);
    private BeanFactory beanFactory;

    @Around("@annotation(timeout)")
    public Object timeoutAround(ProceedingJoinPoint pjp, Timeout timeout) throws Throwable {
        // Core logic implemented in section 2.3
        return timeoutAround(pjp, timeout);
    }

    // Helper methods defined in sections 2.4‑2.6
    private Executor getExecutor(String executorBean) { /* ... */ }
    private Object handleFallback(ProceedingJoinPoint pjp, String fallbackMethod, Exception e) throws Exception { /* ... */ }
    private Method getFallbackMethod(ProceedingJoinPoint pjp, String fallback) { /* ... */ }
    private Object[] getParamValues(Throwable e, Method method, Object... args) { /* ... */ }
    private long resolveTimeout(Method method, Timeout timeout) { /* ... */ }

    @Override
    public void setBeanFactory(BeanFactory beanFactory) throws BeansException {
        this.beanFactory = beanFactory;
    }
}</code>

2.3 Core timeoutAround Method

<code>public Object timeoutAround(ProceedingJoinPoint pjp, Timeout timeout) throws Throwable {
    Method method = ((MethodSignature) pjp.getSignature()).getMethod();
    long timeoutMs = resolveTimeout(method, timeout);
    int retry = timeout.retry();
    String fallbackMethod = timeout.fallback();
    Executor executor = getExecutor(timeout.executor());
    int attempt = 0;
    do {
        try {
            Future<Object> future = ((ExecutorService) executor).submit(() -> {
                try { return pjp.proceed(); }
                catch (Throwable e) { throw new RuntimeException(e); }
            });
            return future.get(timeoutMs, TimeUnit.MILLISECONDS);
        } catch (TimeoutException e) {
            logger.warn("{} - call timed out, {}", method, e.getMessage());
            if (attempt++ >= retry) {
                return handleFallback(pjp, fallbackMethod, e);
            }
            long waitTime = timeout.retryDelay() * (long) Math.pow(2, attempt - 1);
            TimeUnit.MILLISECONDS.sleep(waitTime);
            logger.warn("Retry {} after {} ms", attempt, waitTime);
        } catch (Exception e) {
            throw e.getCause();
        }
    } while (true);
}</code>

2.4 getExecutor Method

<code>private Executor getExecutor(String executorBean) {
    Executor executor = null;
    if (StringUtils.hasLength(executorBean)) {
        try {
            executor = this.beanFactory.getBean(executorBean, ExecutorService.class);
        } catch (Exception e) {
            int core = Runtime.getRuntime().availableProcessors();
            executor = new ThreadPoolExecutor(core, core, 60, TimeUnit.SECONDS,
                new ArrayBlockingQueue<>(1024));
        }
    }
    return executor;
}</code>

2.5 getFallbackMethod Method

<code>private Method getFallbackMethod(ProceedingJoinPoint pjp, String fallback) {
    MethodSignature ms = (MethodSignature) pjp.getSignature();
    Method method = ms.getMethod();
    Class<?>[] paramTypes = method.getParameterTypes();
    Method fallbackMethod = null;
    try {
        fallbackMethod = method.getDeclaringClass().getDeclaredMethod(fallback, paramTypes);
        fallbackMethod.setAccessible(true);
    } catch (Exception e) {
        Class<?>[] types = new Class<?>[paramTypes.length + 1];
        System.arraycopy(paramTypes, 0, types, 0, paramTypes.length);
        types[types.length - 1] = Throwable.class;
        try {
            fallbackMethod = method.getDeclaringClass().getDeclaredMethod(fallback, types);
        } catch (Exception ex) {
            logger.error("Failed to obtain fallback method: {}", ex.getMessage());
        }
    }
    return fallbackMethod;
}</code>

2.6 resolveTimeout Method

<code>private long resolveTimeout(Method method, Timeout timeout) {
    String expr = timeout.value();
    DefaultListableBeanFactory bf = (DefaultListableBeanFactory) beanFactory;
    String embedded = bf.resolveEmbeddedValue(expr);
    Object value = bf.getBeanExpressionResolver().evaluate(embedded,
        new BeanExpressionContext(bf, null));
    Long ms = bf.getConversionService().convert(value, Long.class);
    return timeout.unit().toMillis(ms);
}</code>

2.7 Test Controller

<code>@RestController
@RequestMapping("/api")
public class ApiController {

    @Timeout(
        value = "${pack.app.api.timeout}",
        unit = TimeUnit.SECONDS,
        fallback = "fallbackQuery",
        retry = 3,
        retryDelay = 3000)
    @GetMapping("/query")
    public ResponseEntity<String> query() throws Throwable {
        TimeUnit.SECONDS.sleep(new Random().nextInt(6));
        return ResponseEntity.ok("success");
    }

    public ResponseEntity<String> fallbackQuery(Throwable e) {
        return ResponseEntity.ok("接口超时");
    }
}
</code>

2.8 Configuration

<code>pack:
  app:
    api:
      timeout: 3
</code>

2.9 Test Results

Test result 1
Test result 1
Test result 2
Test result 2
Test result 3
Test result 3

After retries, the request eventually succeeds; if all retries fail, the fallback response "接口超时" is returned.

BackendJavaAOPSpring BootCustom AnnotationAPI timeout
Spring Full-Stack Practical Cases
Written by

Spring Full-Stack Practical Cases

Full-stack Java development with Vue 2/3 front-end suite; hands-on examples and source code analysis for Spring, Spring Boot 2/3, and Spring Cloud.

0 followers
Reader feedback

How this landed with the community

login Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.