skip to content

How does @Around advice handle exceptions and short-circuiting, and how would you implement retry or caching with proceed()?

level: seniorimportance: should knowfreq 48%

answer

  1. proceed() throws Throwable
  2. 0 calls = short-circuit/cache
  3. N calls = retry (idempotent only)
  4. rethrow original preserves trace
  5. order decides retry-vs-tx nesting

basics

~20 s

proceed() throws Throwable, so you can wrap it in try/catch to retry, translate, suppress, or add context. To short-circuit (e.g. cache hit), skip proceed() and return your own value. To retry, call proceed() again in a loop.

solid answer

~50 s

Because proceed() re-invokes the target and is declared throws Throwable, @Around gives full control over the exception path. Wrap proceed() in try/catch to translate exceptions, add context, suppress-and-default, or retry by calling proceed() again in a loop until it succeeds or attempts are exhausted. You can call proceed() zero times (short-circuit — the target never runs, you return a substitute, which is how caching works: check the cache, return on hit, else proceed() and store), once (normal), or multiple times (retry). Whatever you re-throw propagates to the caller as if the method threw it; whatever you return becomes the result. Caveats: only exceptions thrown through the proxy are seen (self-invocation bypasses it); rethrow the original Throwable to preserve stack traces rather than wrapping blindly; and repeated proceed() calls re-run side effects, so retry is only safe for idempotent operations.

code

java · 18 lines
java
@Aspect
@Component
public class RetryAspect {

    @Around("@annotation(com.example.Retryable)")
    public Object retry(ProceedingJoinPoint pjp) throws Throwable {
        Throwable last = null;
        for (int attempt = 1; attempt <= 3; attempt++) {
            try {
                return pjp.proceed();               // may be called multiple times
            } catch (TransientDataAccessException ex) {
                last = ex;                           // retry only transient failures
                Thread.sleep(100L * attempt);        // simple backoff
            }
        }
        throw last;                                  // exhausted -> propagate original
    }
}

go deeper

for a junior

Know proceed() can throw and you can try/catch around it; skipping it short-circuits.

for a middle

Implement a basic cache (0 calls) and translate exceptions with the cause preserved.

for a senior

Reason about retry idempotency, exception fidelity, and finally-based cleanup around proceed().

for a principal

Analyze aspect ordering (retry vs transaction nesting), circuit-breaker semantics, and observability of swallowed errors.

## Why @Around owns the exception path `ProceedingJoinPoint.proceed()` is declared `throws Throwable`. Any exception the target throws surfaces out of `proceed()`. Because you write the call, you decide what happens next: let it propagate, catch and translate, swallow and default, or retry. ```java @Around("...") public Object around(ProceedingJoinPoint pjp) throws Throwable { try { return pjp.proceed(); } catch (DataAccessException ex) { throw new ServiceException("lookup failed", ex); // translate } } ``` Whatever you **re-throw** propagates to the caller exactly as if the method itself threw it. Whatever you **return** becomes the method's result. If you catch and neither re-throw nor return a sensible value, you've swallowed the error — usually a bug. ## proceed() call counts - **Zero calls (short-circuit):** the target never runs. You return a substitute. This is the mechanism behind **caching**, **feature flags**, **rate-limit rejection**, and **circuit breakers**. - **Once:** normal wrapping. - **Multiple calls:** **retry**. Each call re-invokes the target from scratch. ## Caching pattern (short-circuit on hit) ```java @Around("@annotation(Cacheable)") public Object cache(ProceedingJoinPoint pjp) throws Throwable { Object key = Arrays.asList(pjp.getArgs()); Object hit = store.get(key); if (hit != null) { return hit; // short-circuit: target NOT called } Object result = pjp.proceed(); store.put(key, result); return result; } ``` (In real apps prefer Spring's `@Cacheable` — this shows the mechanism.) ## Retry pattern (multiple proceed() calls) ```java @Around("@annotation(Retryable)") public Object retry(ProceedingJoinPoint pjp) throws Throwable { int max = 3; Throwable last = null; for (int attempt = 1; attempt <= max; attempt++) { try { return pjp.proceed(); // re-invoke target each time } catch (TransientException ex) { last = ex; // retry only on transient errors } } throw last; // exhausted } ``` **Idempotency warning:** calling `proceed()` again re-runs *all* the target's side effects (DB writes, HTTP calls). Retry is safe only for idempotent operations; otherwise you risk duplicate work. ## Preserving stack traces / exception fidelity - Prefer **re-throwing the original Throwable** (`throw ex;`) to keep the true stack trace. - If you wrap, **pass the cause** (`new X(msg, ex)`) so nothing is lost. - Catching `Throwable` too broadly can hide `Error`s (e.g. `OutOfMemoryError`); usually catch specific exceptions. - Beware catching a checked exception the target can't actually throw — the compiler forces `throws Throwable` on the advice anyway. ## Gotchas - **Self-invocation:** an internal `this.method()` call bypasses the proxy, so the aspect (and thus the retry/cache) never fires. - **Ordering with other aspects:** if multiple `@Around` aspects apply, `@Order` / `Ordered` decides nesting; the lowest order is outermost. A retry aspect and a transaction aspect nest differently depending on order — retry-outside-transaction vs retry-inside matters a lot. - **finally for cleanup:** use try/finally around proceed() for guaranteed cleanup (close resource, stop timer) regardless of outcome. - **Swallowing on purpose** (return a default on failure) changes observable behavior — document it. ## When to use Use the exception/short-circuit power of `@Around` for retry, caching, circuit-breaking, fallback/default-on-error, and exception translation at a boundary. For simple 'log the exception' needs, `@AfterThrowing` is simpler and cannot accidentally swallow the error.

  • Why is retry via repeated proceed() dangerous for non-idempotent methods?
    Each proceed() re-executes the target's full body, including side effects like DB inserts or payment calls. Retrying a non-idempotent op can duplicate those effects (double charge, duplicate row), so retry is only safe when the operation is idempotent.
  • If a retry @Around and a @Transactional aspect both apply, why does ordering matter?
    Order decides nesting. If retry is outermost, each attempt gets a fresh transaction (retry-around-transaction) — usually what you want. If the transaction is outermost, a failed attempt may have marked the transaction rollback-only, so retrying inside it fails. Use @Order to place retry outside the transaction.

saying these in an interview costs you the question

  • Catching and swallowing exceptions without rethrowing or returning a sensible default
  • Wrapping exceptions without passing the cause, losing the stack trace
  • Assuming retry is safe for any method regardless of idempotency
  • Not realizing self-invocation bypasses the aspect entirely
  • Catching Throwable broadly and hiding Errors like OutOfMemoryError

context