Skip to content

Skip Handlers

Garvit Joshi edited this page Jan 28, 2026 · 7 revisions

Skip Handlers

Skip handlers define what happens when a lock, semaphore permit, or rate limit cannot be acquired. Locksmith provides built-in handlers and supports custom implementations for locks, semaphores, and rate limits.

How It Works

When acquisition fails:

  1. Locksmith creates a context object (LockContext or SemaphoreContext) with method details
  2. The configured skip handler's handle() method is invoked
  3. The handler returns a value (or throws an exception)
  4. That value becomes the method's return value
Acquisition Failed
       │
       ▼
 ┌─────────────┐
 │   Context   │
 │ - key       │
 │ - method    │
 │ - args      │
 │ - returnType│
 └─────────────┘
       │
       ▼
 ┌─────────────┐
 │ SkipHandler │
 │  .handle()  │
 └─────────────┘
       │
       ▼
 Return Value or Exception

Lock Skip Handlers

Built-in Lock Handlers

LockThrowExceptionHandler (Default)

Throws LockNotAcquiredException:

@DistributedLock(key = "task")  // Uses LockThrowExceptionHandler by default
public void task() { }

// Equivalent to:
@DistributedLock(key = "task", skipHandler = LockThrowExceptionHandler.class)
public void task() { }

Calling code must handle the exception:

try {
    service.task();
} catch (LockNotAcquiredException e) {
    log.warn("Lock not acquired: {}", e.getLockKey());
    // Retry, return cached value, etc.
}

LockReturnDefaultHandler

Returns type-appropriate default values:

@DistributedLock(key = "task", skipHandler = LockReturnDefaultHandler.class)
public String getData() {
    return fetchData();
}
// If lock not acquired: returns null

Default values by type:

Return Type Default Value
Objects null
boolean / Boolean false
byte / Byte 0
short / Short 0
int / Integer 0
long / Long 0L
float / Float 0.0f
double / Double 0.0
char / Character '\0'
Optional Optional.empty()
void null

LockContext

The context passed to lock skip handlers:

import in.riido.locksmith.models.LockContext;

public record LockContext(
    String lockKey,      // Full Redis key: "lock:my-task"
    String methodName,   // Formatted: "MyService.myMethod"
    Method method,       // java.lang.reflect.Method
    Object[] args,       // Method arguments
    Class<?> returnType  // Method return type
) { }

Custom Lock Skip Handlers

Implement LockSkipHandler for custom behavior:

public interface LockSkipHandler {
    Object handle(LockContext context);
}

Example: Fallback Value

public class FallbackValueHandler implements LockSkipHandler {

    @Override
    public Object handle(LockContext context) {
        if (context.returnType() == List.class) {
            return Collections.emptyList();
        }
        if (context.returnType() == Optional.class) {
            return Optional.empty();
        }
        return null;
    }
}

@DistributedLock(key = "users", skipHandler = FallbackValueHandler.class)
public List<User> getUsers() {
    return userRepository.findAll();
}
// If lock not acquired: returns empty list instead of null

Example: Alerting

public class AlertingHandler implements LockSkipHandler {

    private static final Logger log = LoggerFactory.getLogger(AlertingHandler.class);

    @Override
    public Object handle(LockContext context) {
        // Log for monitoring
        log.warn("Lock acquisition failed: {} for method {}",
            context.lockKey(), context.methodName());

        // Send metric
        Metrics.counter("lock.acquisition.failed",
            "key", context.lockKey()).increment();

        // Still throw exception
        throw new LockNotAcquiredException(context.lockKey(), context.methodName());
    }
}

Semaphore Skip Handlers

Built-in Semaphore Handlers

SemaphoreThrowExceptionHandler (Default)

Throws SemaphoreNotAcquiredException:

@DistributedSemaphore(key = "pool", permits = 5)  // Uses SemaphoreThrowExceptionHandler by default
public void task() { }

// Equivalent to:
@DistributedSemaphore(key = "pool", permits = 5, skipHandler = SemaphoreThrowExceptionHandler.class)
public void task() { }

Calling code must handle the exception:

try {
    service.task();
} catch (SemaphoreNotAcquiredException e) {
    log.warn("No permit available: {}", e.getSemaphoreKey());
    // Retry, return cached value, etc.
}

SemaphoreReturnDefaultHandler

Returns type-appropriate default values (same as LockReturnDefaultHandler):

@DistributedSemaphore(key = "pool", permits = 5, skipHandler = SemaphoreReturnDefaultHandler.class)
public String getData() {
    return fetchData();
}
// If no permit available: returns null

SemaphoreContext

The context passed to semaphore skip handlers:

import in.riido.locksmith.models.SemaphoreContext;

public record SemaphoreContext(
    String semaphoreKey, // Full Redis key: "semaphore:my-pool"
    String methodName,   // Formatted: "MyService.myMethod"
    Method method,       // java.lang.reflect.Method
    Object[] args,       // Method arguments
    Class<?> returnType, // Method return type
    String permitId      // Permit ID if acquired, null otherwise
) { }

Custom Semaphore Skip Handlers

Implement SemaphoreSkipHandler for custom behavior:

public interface SemaphoreSkipHandler {
    Object handle(SemaphoreContext context);
}

Example: Rate Limit Response

public class RateLimitHandler implements SemaphoreSkipHandler {

    @Override
    public Object handle(SemaphoreContext context) {
        log.warn("Rate limited: {} for method {}",
            context.semaphoreKey(), context.methodName());

        // Return a rate limit response
        return new ApiResponse(429, "Too Many Requests");
    }
}

@DistributedSemaphore(key = "api-calls", permits = 10, skipHandler = RateLimitHandler.class)
public ApiResponse callExternalApi() {
    return httpClient.get("/data");
}

Example: Queue for Later

public class QueueSemaphoreHandler implements SemaphoreSkipHandler {

    private final Queue<Runnable> retryQueue;

    public QueueSemaphoreHandler() {
        this.retryQueue = new ConcurrentLinkedQueue<>();
    }

    @Override
    public Object handle(SemaphoreContext context) {
        log.info("No permit available, queuing {} for later", context.methodName());

        // Queue the operation for later
        retryQueue.add(() -> {
            // Re-invoke logic here
        });

        return null;
    }
}

Rate Limit Skip Handlers

Built-in Rate Limit Handlers

RateLimitThrowExceptionHandler (Default)

Throws RateLimitExceededException:

@RateLimit(key = "api", permits = 100, interval = "1m")  // Uses RateLimitThrowExceptionHandler by default
public void api() { }

// Equivalent to:
@RateLimit(key = "api", permits = 100, interval = "1m", skipHandler = RateLimitThrowExceptionHandler.class)
public void api() { }

Calling code must handle the exception:

try {
    service.api();
} catch (RateLimitExceededException e) {
    log.warn("Rate limit exceeded: {}", e.getRateLimitKey());
    // Retry later, return cached value, etc.
}

RateLimitReturnDefaultHandler

Returns type-appropriate default values (same as LockReturnDefaultHandler):

@RateLimit(key = "api", permits = 100, interval = "1m", skipHandler = RateLimitReturnDefaultHandler.class)
public String getData() {
    return fetchData();
}
// If rate limit exceeded: returns null

RateLimitContext

The context passed to rate limit skip handlers:

import in.riido.locksmith.models.RateLimitContext;

public record RateLimitContext(
    String rateLimitKey, // Full Redis key: "ratelimit:my-api"
    String methodName,   // Formatted: "MyService.myMethod"
    Method method,       // java.lang.reflect.Method
    Object[] args,       // Method arguments
    Class<?> returnType  // Method return type
) { }

Custom Rate Limit Skip Handlers

Implement RateLimitSkipHandler for custom behavior:

public interface RateLimitSkipHandler {
    Object handle(RateLimitContext context);
}

Example: HTTP 429 Response

public class Http429Handler implements RateLimitSkipHandler {

    @Override
    public Object handle(RateLimitContext context) {
        log.warn("Rate limit exceeded: {} for method {}",
            context.rateLimitKey(), context.methodName());

        return ResponseEntity.status(HttpStatus.TOO_MANY_REQUESTS)
            .header("Retry-After", "60")
            .body("Rate limit exceeded. Please retry later.");
    }
}

@RateLimit(key = "public-api", permits = 100, interval = "1m", skipHandler = Http429Handler.class)
public ResponseEntity<?> publicApi() {
    return ResponseEntity.ok(data);
}

Example: Queue for Retry

public class QueueRateLimitHandler implements RateLimitSkipHandler {

    private final RetryQueue retryQueue;

    public QueueRateLimitHandler(RetryQueue retryQueue) {
        this.retryQueue = retryQueue;
    }

    @Override
    public Object handle(RateLimitContext context) {
        log.info("Rate limited, queuing {} for later", context.methodName());

        retryQueue.enqueue(context.args());

        return new AsyncResult("Request queued for processing");
    }
}

Real-World Patterns

Pattern: Graceful Degradation

Works for both locks and semaphores:

public class GracefulDegradationHandler implements LockSkipHandler {

    @Override
    public Object handle(LockContext context) {
        log.info("Gracefully degrading {} due to lock contention",
            context.methodName());

        // Return stale data, cached value, or partial result
        return getStaleData(context);
    }

    private Object getStaleData(LockContext context) {
        // Implementation
        return null;
    }
}

Pattern: Circuit Breaker Integration

public class CircuitBreakerHandler implements SemaphoreSkipHandler {

    private final CircuitBreaker circuitBreaker;

    public CircuitBreakerHandler() {
        this.circuitBreaker = CircuitBreaker.ofDefaults("api-circuit");
    }

    @Override
    public Object handle(SemaphoreContext context) {
        // Trip circuit breaker after repeated failures
        circuitBreaker.recordFailure();

        if (circuitBreaker.isOpen()) {
            return getFallback(context);
        }

        throw new SemaphoreNotAcquiredException(context.semaphoreKey(), context.methodName());
    }

    private Object getFallback(SemaphoreContext context) {
        return null;
    }
}

Pattern: Method-Specific Logic

public class SmartHandler implements LockSkipHandler {

    @Override
    public Object handle(LockContext context) {
        String methodName = context.method().getName();

        switch (methodName) {
            case "getUsers":
                return Collections.emptyList();
            case "getCount":
                return 0;
            case "isEnabled":
                return false;
            default:
                throw new LockNotAcquiredException(
                    context.lockKey(), context.methodName());
        }
    }
}

Pattern: Using Method Arguments

public class ArgumentAwareHandler implements SemaphoreSkipHandler {

    @Override
    public Object handle(SemaphoreContext context) {
        Object[] args = context.args();

        if (args.length > 0 && args[0] instanceof String userId) {
            log.warn("Could not acquire permit for user: {}", userId);
            return getCachedUserData(userId);
        }

        return null;
    }

    private Object getCachedUserData(String userId) {
        // Fetch from cache
        return null;
    }
}

Spring Bean Integration

Locksmith fully supports Spring dependency injection for skip handlers. Handler resolution follows this order:

  1. Spring Bean Lookup - First looks up the handler as a Spring bean by type from ApplicationContext
  2. Reflection Fallback - If not found as a bean, instantiates via reflection (requires public no-arg constructor)

This means you can define handlers as Spring @Component beans with full dependency injection support:

Example: Spring Bean Handler with DI

@Component
public class AlertingSkipHandler implements LockSkipHandler {

    private final AlertService alertService;
    private final MetricRegistry metrics;

    // Constructor injection - fully supported!
    public AlertingSkipHandler(AlertService alertService, MetricRegistry metrics) {
        this.alertService = alertService;
        this.metrics = metrics;
    }

    @Override
    public Object handle(LockContext context) {
        // Use injected services
        alertService.sendAlert("Lock acquisition failed: " + context.lockKey());
        metrics.counter("lock.skipped").increment();

        return null;
    }
}

// Usage - just reference the class, Spring handles instantiation
@DistributedLock(key = "task", skipHandler = AlertingSkipHandler.class)
public void criticalTask() { }

Example: Semaphore Handler with Spring Services

@Component
public class CacheFallbackHandler implements SemaphoreSkipHandler {

    private final CacheManager cacheManager;

    public CacheFallbackHandler(CacheManager cacheManager) {
        this.cacheManager = cacheManager;
    }

    @Override
    public Object handle(SemaphoreContext context) {
        Cache cache = cacheManager.getCache("fallback");
        return cache.get(context.semaphoreKey(), context.returnType());
    }
}

@DistributedSemaphore(key = "api", permits = 10, skipHandler = CacheFallbackHandler.class)
public ApiResponse callExternalApi() { }

Simple Handlers (No Spring Dependencies)

For simple handlers that don't need Spring beans, just implement the interface with a no-arg constructor:

// No @Component needed - will be instantiated via reflection
public class LoggingSkipHandler implements LockSkipHandler {

    @Override
    public Object handle(LockContext context) {
        System.out.println("Lock acquisition failed for: " + context.lockKey());
        return null;
    }
}

Note: Handler instances are cached per type for performance. Ensure your handlers are stateless and thread-safe.


Best Practices

1. Choose Handler Based on Use Case

Scenario Lock Handler Semaphore Handler Rate Limit Handler
Critical operations LockThrowExceptionHandler SemaphoreThrowExceptionHandler RateLimitThrowExceptionHandler
Scheduled jobs LockReturnDefaultHandler SemaphoreReturnDefaultHandler RateLimitReturnDefaultHandler
User-facing with fallback Custom handler Custom handler Custom handler
API endpoints - - Custom HTTP 429 handler
Monitoring required Custom handler with metrics Custom handler with metrics Custom handler with metrics

2. Keep Handlers Fast

// Good - fast
public Object handle(LockContext context) {
    return cachedValue;
}

// Bad - slow
public Object handle(LockContext context) {
    return expensiveRemoteCall();  // Don't do this!
}

3. Handle All Return Types

public Object handle(LockContext context) {
    Class<?> type = context.returnType();

    if (type == void.class) return null;
    if (type == Optional.class) return Optional.empty();
    if (List.class.isAssignableFrom(type)) return Collections.emptyList();
    if (Map.class.isAssignableFrom(type)) return Collections.emptyMap();
    // ... handle other types

    return null;  // Fallback
}

4. Log Appropriately

public Object handle(LockContext context) {
    // INFO for expected skips
    log.info("Skipping {} - lock held", context.methodName());

    // WARN for unexpected situations
    log.warn("Could not acquire critical lock: {}", context.lockKey());

    return null;
}

5. Be Thread-Safe

Handlers are cached and reused. Do not store state in instance variables:

// Bad - not thread-safe
public class BadHandler implements LockSkipHandler {
    private int counter = 0;  // Don't do this!

    @Override
    public Object handle(LockContext context) {
        counter++;  // Race condition!
        return null;
    }
}

// Good - thread-safe
public class GoodHandler implements LockSkipHandler {

    @Override
    public Object handle(LockContext context) {
        // Use local variables or thread-safe structures
        return null;
    }
}

Next Steps

Clone this wiki locally