gregpriday/laravel-retry

A flexible retry mechanism for Laravel applications

0.1.0 2024-11-23 13:55 UTC

This package is auto-updated.

Last update: 2025-03-07 03:53:05 UTC


README

A robust and flexible retry mechanism for Laravel applications that provides a powerful way to handle transient failures. This package helps you handle temporary failures in HTTP requests, database queries, API calls, and any other potentially unstable operations.

Features

  • Multiple Retry Strategies

    • Exponential Backoff (with optional jitter)
    • Linear Backoff
    • Fixed Delay
    • Decorrelated Jitter (AWS-style)
    • Rate Limiting
    • Circuit Breaker pattern
    • Composable strategies for complex scenarios
  • Promise-like Result Handling

    • Chain success/failure handlers
    • Handle errors gracefully
    • Access attempt history
    • Fluent interface
  • Built-in Exception Handling

    • Automatic detection of retryable errors
    • Extensible exception handler system
    • Built-in support for Guzzle exceptions
    • Custom error pattern matching
  • Comprehensive Configuration

    • Configurable retry attempts
    • Adjustable delays and timeouts
    • Progress tracking and logging
    • Custom retry conditions

Installation

You can install the package via Composer:

composer require gregpriday/laravel-retry

After installation, you can publish the configuration file:

php artisan vendor:publish --tag="retry-config"

Basic Usage

Simple Retry Operation

use GregPriday\LaravelRetry\Facades\Retry;

$result = Retry::run(function () {
    return Http::get('https://api.example.com/data');
})->then(function ($response) {
    // Handle successful response
    return $response->json();
})->catch(function (Throwable $e) {
    // Handle complete failure after all retries
    Log::error('API call failed', ['error' => $e->getMessage()]);
    return null;
});

Using Dependency Injection

use GregPriday\LaravelRetry\Retry;

class ApiService
{
    public function __construct(
        private Retry $retry
    ) {}

    public function fetchData()
    {
        return $this->retry
            ->maxRetries(5)
            ->retryDelay(1)
            ->run(fn() => Http::get('https://api.example.com/data'))
            ->value();
    }
}

Configuring Retry Behavior

Retry::make()
    ->maxRetries(5)                    // Maximum retry attempts
    ->retryDelay(2)                    // Base delay in seconds
    ->timeout(30)                      // Timeout per attempt
    ->withProgress(function ($message) {
        Log::info($message);           // Track progress
    })
    ->run(function () {
        return DB::table('users')->get();
    });

Custom Retry Conditions

Retry::make()
    ->retryIf(function (Throwable $e, array $context) {
        // Retry on rate limit errors if we have attempts remaining
        return $e instanceof RateLimitException && 
               $context['remaining_attempts'] > 0;
    })
    ->run(function () {
        return $api->fetch();
    });

// Or use retryUnless for inverse conditions
Retry::make()
    ->retryUnless(function (Throwable $e, array $context) {
        // Don't retry if we've seen too many of the same error
        return $context['attempt'] >= 2;
    })
    ->run(function () {
        return $api->fetch();
    });

Retry Strategies

The package includes several retry strategies to handle different scenarios:

Exponential Backoff (Default)

Increases delay exponentially between retries:

use GregPriday\LaravelRetry\Strategies\ExponentialBackoffStrategy;

$strategy = new ExponentialBackoffStrategy(
    multiplier: 2.0,    // Each delay will be 2x longer
    maxDelay: 30,       // Maximum delay in seconds
    withJitter: true    // Add randomness to prevent thundering herd
);

Retry::withStrategy($strategy)->run(fn() => doSomething());

Linear Backoff

Increases delay linearly between retries:

use GregPriday\LaravelRetry\Strategies\LinearBackoffStrategy;

$strategy = new LinearBackoffStrategy(
    increment: 5,     // Add 5 seconds each retry
    maxDelay: 30      // Maximum delay in seconds
);

Fixed Delay

Uses the same delay between all retries:

use GregPriday\LaravelRetry\Strategies\FixedDelayStrategy;

$strategy = new FixedDelayStrategy(
    withJitter: true,       // Add randomness
    jitterPercent: 0.2     // ±20% jitter
);

Decorrelated Jitter

Implements AWS's "Exponential Backoff and Jitter" algorithm:

use GregPriday\LaravelRetry\Strategies\DecorrelatedJitterStrategy;

$strategy = new DecorrelatedJitterStrategy(
    maxDelay: 30,       // Maximum delay in seconds
    minFactor: 1.0,     // Minimum multiplier for base delay
    maxFactor: 3.0      // Maximum multiplier for base delay
);

Rate Limit Strategy

Implements rate limiting across multiple retry attempts:

use GregPriday\LaravelRetry\Strategies\RateLimitStrategy;

$strategy = new RateLimitStrategy(
    innerStrategy: new ExponentialBackoffStrategy(),
    maxAttempts: 100,    // Maximum attempts per time window
    timeWindow: 60       // Time window in seconds
);

Circuit Breaker Strategy

Implements the Circuit Breaker pattern:

use GregPriday\LaravelRetry\Strategies\CircuitBreakerStrategy;

$strategy = new CircuitBreakerStrategy(
    innerStrategy: new ExponentialBackoffStrategy(),
    failureThreshold: 5,    // Failures before opening circuit
    resetTimeout: 60        // Seconds before attempting reset
);

Combining Strategies

Strategies can be combined for complex scenarios:

$rateLimit = new RateLimitStrategy(
    new ExponentialBackoffStrategy(),
    maxAttempts: 100,
    timeWindow: 60
);

$circuitBreaker = new CircuitBreakerStrategy(
    $rateLimit,
    failureThreshold: 5,
    resetTimeout: 60
);

Retry::withStrategy($circuitBreaker)->run(fn() => doSomething());

Result Handling

The package provides a promise-like interface for handling results:

$result = Retry::run(fn() => riskyOperation())
    ->then(function($value) {
        // Handle success
        return processValue($value);
    })
    ->catch(function(Throwable $e) {
        // Handle failure
        Log::error('Operation failed', ['error' => $e]);
        return fallbackValue();
    })
    ->finally(function() {
        // Always runs
        cleanup();
    });

// Access the final value (throws on error)
$value = $result->value();

// Or handle success/failure states explicitly
if ($result->succeeded()) {
    $value = $result->getResult();
} else {
    $error = $result->getError();
}

// Access retry history
$history = $result->getExceptionHistory();

Laravel Retry - Advanced Usage

Exception Handling System

Built-in Exception Handlers

The package includes built-in handlers for common exceptions:

  • Network timeouts
  • Connection errors
  • Rate limiting
  • Server errors
  • SSL/TLS issues
  • Temporary unavailability

Custom Exception Handlers

Create custom handlers by extending the BaseHandler class:

use GregPriday\LaravelRetry\Exceptions\Handlers\BaseHandler;

class CustomDatabaseHandler extends BaseHandler
{
    protected function getHandlerPatterns(): array
    {
        return [
            '/deadlock found/i',
            '/lock wait timeout/i',
            '/connection lost/i'
        ];
    }

    protected function getHandlerExceptions(): array
    {
        return [
            \PDOException::class,
            \Doctrine\DBAL\Exception\DeadlockException::class,
            \Illuminate\Database\QueryException::class
        ];
    }

    public function isApplicable(): bool
    {
        return true;
    }
}

Register custom handlers:

// In a service provider
use GregPriday\LaravelRetry\Exceptions\ExceptionHandlerManager;

public function boot(ExceptionHandlerManager $manager)
{
    $manager->registerHandler(new CustomDatabaseHandler());
}

Exception History

Track and analyze retry attempts:

$result = Retry::run(fn() => riskyOperation());

foreach ($result->getExceptionHistory() as $entry) {
    Log::info('Retry attempt details', [
        'attempt' => $entry['attempt'],
        'exception' => $entry['exception']->getMessage(),
        'timestamp' => $entry['timestamp'],
        'was_retryable' => $entry['was_retryable']
    ]);
}

Testing & Debugging

Testing Retry Logic

The package is designed for easy testing:

use GregPriday\LaravelRetry\Tests\TestCase;

class MyServiceTest extends TestCase
{
    public function test_it_retries_failed_operations()
    {
        $counter = 0;
        
        $result = $this->retry
            ->maxRetries(3)
            ->run(function() use (&$counter) {
                $counter++;
                if ($counter < 3) {
                    throw new Exception('Temporary failure');
                }
                return 'success';
            });

        $this->assertTrue($result->succeeded());
        $this->assertEquals(3, $counter);
        $this->assertCount(2, $result->getExceptionHistory());
    }
}

Debugging Tools

Monitor retry operations:

Retry::make()
    ->withProgress(function ($message) {
        Log::channel('retry')->info($message);
        
        // Or send to monitoring service
        Monitoring::recordRetryAttempt([
            'message' => $message,
            'timestamp' => now()
        ]);
    })
    ->run(fn() => operation());

Advanced Configuration

Global Configuration

Customize default behavior in config/retry.php:

return [
    'max_retries' => env('RETRY_MAX_ATTEMPTS', 3),
    'delay' => env('RETRY_DELAY', 5),
    'timeout' => env('RETRY_TIMEOUT', 120),
    'handler_paths' => [
        app_path('Exceptions/Retry/Handlers')
    ],
];

Runtime Configuration

Apply configuration per operation:

Retry::make()
    ->maxRetries(5)
    ->retryDelay(2)
    ->timeout(30)
    ->withStrategy(new CustomStrategy())
    ->withProgress($callback)
    ->retryIf($condition)
    ->run(fn() => operation());

Service Container Bindings

Customize the service registration:

use GregPriday\LaravelRetry\Retry;
use GregPriday\LaravelRetry\Strategies\ExponentialBackoffStrategy;

$this->app->bind(Retry::class, function ($app) {
    return new Retry(
        maxRetries: 5,
        retryDelay: 1,
        timeout: 30,
        strategy: new ExponentialBackoffStrategy(
            multiplier: 2.0,
            maxDelay: 30,
            withJitter: true
        )
    );
});

Performance Considerations

Memory Usage

The package maintains an exception history for each retry operation. For long-running processes or high-frequency retries, consider:

// Clear exception history between runs if needed
$retry = Retry::make();

foreach ($items as $item) {
    $result = $retry->run(fn() => processItem($item));
    // History is automatically reset on next run
}

Timeout Handling

Set appropriate timeouts to prevent long-running operations:

Retry::make()
    ->timeout(5)  // Maximum time per attempt
    ->run(function() {
        return Http::timeout(3)->get('https://api.example.com');
    });

Common Patterns & Best Practices

Idempotency

Ensure operations are safe to retry:

Retry::run(function() {
    return DB::transaction(function() {
        // Use idempotency keys or check existence
        if (!Payment::whereReference($ref)->exists()) {
            return Payment::create([...]);
        }
    });
});

Rate Limiting

Handle API rate limits:

$strategy = new RateLimitStrategy(
    new ExponentialBackoffStrategy(),
    maxAttempts: 100,
    timeWindow: 60,
    storageKey: 'api-client'  // Separate limits per client
);

Retry::withStrategy($strategy)
    ->retryIf(function($e) {
        return $e instanceof RateLimitException;
    })
    ->run(fn() => apiCall());

Circuit Breaking

Protect downstream services:

$circuitBreaker = new CircuitBreakerStrategy(
    new ExponentialBackoffStrategy(),
    failureThreshold: 5,
    resetTimeout: 60
);

$retry = Retry::make()->withStrategy($circuitBreaker);

while (true) {
    try {
        $result = $retry->run(fn() => serviceCall());
        // Process result...
    } catch (Exception $e) {
        // Circuit is open, wait before retrying
        sleep($circuitBreaker->getResetTimeout());
    }
}

Contributing

We welcome contributions! Please see CONTRIBUTING.md for details.

Development Setup

  1. Clone the repository
  2. Install dependencies:
composer install

Running Tests

composer test

Style fixing:

composer format

Submitting Changes

  1. Fork the repository
  2. Create a feature branch
  3. Commit your changes
  4. Push to the branch
  5. Create a Pull Request

License

The MIT License (MIT). Please see License File for more information.

Support

Credits