yannelli / attempt
A fluent attempt/retry/fallback system for Laravel
Fund package maintenance!
yannelli
Installs: 11
Dependents: 0
Suggesters: 0
Security: 0
Stars: 1
Watchers: 0
Forks: 0
Open Issues: 0
pkg:composer/yannelli/attempt
Requires
- php: ^8.2
- illuminate/contracts: ^11.0|^12.0
- illuminate/pipeline: ^11.0|^12.0
- illuminate/queue: ^11.0|^12.0
- illuminate/support: ^11.0|^12.0
- spatie/laravel-package-tools: ^1.16
Requires (Dev)
- laravel/pint: ^1.14
- nunomaduro/collision: ^8.1
- orchestra/testbench: ^9.0|^10.0
- pestphp/pest: ^2.34|^3.0
- pestphp/pest-plugin-arch: ^2.7|^3.0
- pestphp/pest-plugin-laravel: ^2.3|^3.0
README
- Introduction
- Installation
- Making Attempts
- Retry Configuration
- Fallback Handlers
- Exception Handling
- Lifecycle Hooks
- Conditional Execution
- Pipeline Integration
- Concurrent Execution
- Async Execution
- Working with Results
- Events
- Testing
- Configuration
Introduction
While building your application, you may encounter operations that can fail due to transient issues like network timeouts, API rate limits, or temporary service unavailability. Rather than letting these failures crash your application or writing repetitive try-catch blocks, Laravel Attempt provides a fluent, composable system for handling retries, fallbacks, and error recovery.
Attempt treats error handling as a first-class pipeline concern, allowing you to declaratively define how your application should respond when things go wrong. Whether you need simple retry logic with exponential backoff, complex fallback chains, or integration with Laravel’s native Pipeline, Attempt provides an expressive API that reads like natural language.
Installation
You may install Attempt into your project using the Composer package manager:
composer require yannelli/attempt
After installing Attempt, you may optionally publish its configuration file using the vendor:publish Artisan command:
php artisan vendor:publish --tag="attempt-config"
Making Attempts
Basic Usage
The simplest way to use Attempt is to wrap a potentially failing operation with the try method. To execute the attempt and retrieve the result, you may call the thenReturn method:
use Yannelli\Attempt\Facades\Attempt; $result = Attempt::try(fn() => $api->call())->thenReturn();
If you need to pass input to your callable, you may provide additional arguments to the try method:
$result = Attempt::try(MyAction::class, $order, $user)->thenReturn();
You may also pass an array of callables to the try method. When an array is provided, each callable will be executed in order as a fallback chain. If the first callable fails, the second will be attempted, and so on:
$result = Attempt::try([ PrimaryProvider::class, BackupProvider::class, CachedResponse::class, ], $payload)->thenReturn();
Attempt provides several methods for executing your attempt and retrieving the result:
| Method | Behavior |
|---|---|
then(Closure $callback) |
Transform and return the final result |
thenReturn() |
Return the processed value directly |
thenReturnOrFail() |
Return the value or throw on failure |
run() |
Return an AttemptResult object with metadata |
get() |
Alias for thenReturn() |
value() |
Alias for thenReturn() |
Attemptable Classes
For more complex operations, you may create dedicated attemptable classes. These classes should implement the Attemptable interface and define a handle method that receives the input and returns a result:
use Yannelli\Attempt\Contracts\Attemptable; class FetchUserData implements Attemptable { public function handle(mixed ...$input): mixed { [$userId] = $input; return Http::get("https://api.example.com/users/{$userId}")->json(); } }
Once you have defined your attemptable class, you may pass its class name to the try method:
$userData = Attempt::try(FetchUserData::class, $userId) ->retry(3) ->thenReturn();
Self-Configuring Classes
Sometimes you may want a class to define its own retry and fallback configuration. To accomplish this, your class may implement both the Attemptable and ConfiguresAttempt interfaces. The configureAttempt method receives an AttemptBuilder instance that you may use to define your preferred configuration:
use Yannelli\Attempt\Contracts\Attemptable; use Yannelli\Attempt\Contracts\ConfiguresAttempt; use Yannelli\Attempt\AttemptBuilder; class ResilientApiCall implements Attemptable, ConfiguresAttempt { public function configureAttempt(AttemptBuilder $attempt): void { $attempt ->retry(3) ->exponentialBackoff(100, 5000) ->withJitter(0.1); } public function handle(mixed ...$input): mixed { return Http::get('https://api.example.com/data')->json(); } }
When using a self-configuring class, the configuration is automatically applied:
$result = Attempt::try(ResilientApiCall::class)->thenReturn();
Retry Configuration
Specifying Retry Attempts
By default, Attempt will not retry a failed operation. To enable retries, call the retry method and specify how many times the operation should be attempted:
Attempt::try($callable) ->retry(3) ->thenReturn();
Delay Strategies
Often, you will want to wait between retry attempts to give transient issues time to resolve. Attempt provides several strategies for configuring delays between retries.
Fixed Delay
To wait a fixed number of milliseconds between all retries, pass an integer to the delay method:
Attempt::try($callable) ->retry(3) ->delay(100) // Wait 100ms between retries ->thenReturn();
Explicit Delays
If you need different delays for each retry attempt, you may pass an array of millisecond values:
Attempt::try($callable) ->retry(3) ->delay([1000, 5000, 15000]) // 1s, 5s, 15s ->thenReturn();
Exponential Backoff
Exponential backoff progressively increases the delay between retries. This strategy is particularly useful when interacting with rate-limited APIs or overloaded services. The exponentialBackoff method accepts a base delay and an optional maximum delay:
Attempt::try($callable) ->retry(5) ->exponentialBackoff(base: 100, max: 30000) // 100ms, 200ms, 400ms, 800ms... ->thenReturn();
Linear Backoff
Linear backoff increases the delay by a fixed increment with each retry:
Attempt::try($callable) ->retry(3) ->linearBackoff(base: 100, increment: 100) // 100ms, 200ms, 300ms ->thenReturn();
Adding Jitter
To prevent multiple failing operations from retrying in lockstep (known as the “thundering herd” problem), you may add randomized jitter to your delays. The withJitter method accepts a percentage value that determines how much variance to apply:
Attempt::try($callable) ->retry(3) ->delay([1000, 5000, 10000]) ->withJitter(0.2) // +/- 20% randomization ->thenReturn();
Custom Delay Functions
For complete control over delay calculation, you may use the delayUsing method with a closure that receives the current attempt number and the exception that triggered the retry:
Attempt::try($callable) ->retry(5) ->delayUsing(fn(int $attempt, ?Throwable $e) => $attempt * 1000) ->thenReturn();
Conditional Retries
Sometimes you may only want to retry an operation for specific types of failures. The retryIf method accepts a closure that receives the thrown exception and returns a boolean indicating whether the operation should be retried:
Attempt::try($callable) ->retry(3) ->retryIf(fn(Throwable $e) => $e instanceof ConnectionException) ->thenReturn();
Fallback Handlers
Defining Fallbacks
When an operation fails after exhausting all retries, you may want to execute a fallback operation instead of throwing an exception. Use the fallback method to define an alternative callable:
Attempt::try(PrimaryApi::class) ->fallback(BackupApi::class) ->thenReturn();
Fallback Chains
You may define multiple fallbacks that will be tried in order. The first successful fallback wins:
Attempt::try(PrimaryApi::class) ->fallback([ SecondaryApi::class, TertiaryApi::class, fn() => Cache::get('fallback_value'), ]) ->thenReturn();
For a more expressive syntax, you may chain multiple orFallback calls:
Attempt::try(PrimaryApi::class) ->orFallback(SecondaryApi::class) ->orFallback(fn() => 'default') ->thenReturn();
The Fallbackable Interface
For fallback classes that need access to the original exception, implement the Fallbackable interface. This interface defines a handleFallback method that receives both the exception and the original input:
use Yannelli\Attempt\Contracts\Fallbackable; class ApiErrorFallback implements Fallbackable { public function handleFallback(Throwable $e, mixed ...$input): mixed { Log::warning('Using fallback due to: ' . $e->getMessage()); return Cache::get('cached_response'); } public function shouldSkip(Throwable $e): bool { // Skip this fallback for certain exceptions return $e instanceof ValidationException; } }
Exception Handling
Catching Exceptions
Attempt allows you to register exception handlers that will be invoked when specific exceptions occur. You may catch specific exception types or all exceptions:
// Catch specific exceptions Attempt::try($callable) ->catch(ConnectionException::class, fn($e) => Log::error($e)) ->catch(TimeoutException::class, fn($e) => Metrics::timeout()) ->thenReturn(); // Catch all exceptions Attempt::try($callable) ->catch(fn(Throwable $e) => Log::error($e)) ->thenReturn();
Re-throwing Exceptions
If you want to execute a handler but still throw the exception afterward, chain the throw method:
Attempt::try($callable) ->catch(fn($e) => Log::error($e)) ->throw() ->thenReturn();
Suppressing Exceptions
To suppress all exceptions and return null on failure, use the quiet method:
Attempt::try($callable) ->quiet() ->thenReturn(); // Returns null on failure
Lifecycle Hooks
Attempt provides several hooks that allow you to execute code at specific points during the attempt lifecycle:
Attempt::try($callable) ->finally(fn($context) => Log::info('Attempt completed')) ->defer(fn($context) => Metrics::record($context->elapsed())) ->onRetry(fn($context, $e) => Log::warning("Retry {$context->attemptNumber}")) ->onSuccess(fn($context, $result) => Cache::put('last_result', $result)) ->onFailure(fn($context, $e) => Alert::send($e)) ->thenReturn();
Conditional Execution
You may conditionally execute an attempt using the when and unless methods:
// Only execute if condition is true Attempt::try($callable) ->when($shouldRun) ->thenReturn(); // Only execute if condition is false Attempt::try($callable) ->unless($shouldSkip) ->thenReturn(); // With closure conditions Attempt::try($callable) ->when(fn() => Feature::active('new-api')) ->thenReturn();
Pipeline Integration
Pipeline Attempts
Attempt integrates seamlessly with Laravel’s Pipeline. Use the pipeline method to execute a series of stages with built-in retry and fallback capabilities:
$result = Attempt::pipeline([ ValidateInput::class, ProcessData::class, SaveToDatabase::class, ]) ->send($data) ->retry(2) ->thenReturn();
Using AttemptPipe
You may also use AttemptPipe within a native Laravel Pipeline to wrap individual stages with retry logic:
use Illuminate\Support\Facades\Pipeline; use Yannelli\Attempt\Pipes\AttemptPipe; $result = Pipeline::send($data) ->through([ AttemptPipe::wrap(ExternalApiCall::class) ->retry(3) ->delay([100, 500, 1000]), ProcessResponse::class, ]) ->thenReturn();
Concurrent Execution
Running Concurrent Attempts
When you need to execute multiple operations simultaneously, use the concurrent method. All operations will run in parallel, and you will receive an array of results:
$concurrent = Attempt::concurrent([ fn() => Http::get('https://api1.example.com'), fn() => Http::get('https://api2.example.com'), fn() => Http::get('https://api3.example.com'), ]); // Run all and get array of AttemptResult objects $results = $concurrent->run(); // Get only successful results $successful = Attempt::concurrent([...])->successful(); // Get only failed results $failed = Attempt::concurrent([...])->failed(); // Get values directly $values = Attempt::concurrent([...])->thenReturn();
Racing Attempts
When you need the result of the first successful operation, use the race method. The first operation to succeed wins, and other operations are abandoned:
$result = Attempt::race([ PrimaryProvider::class, SecondaryProvider::class, TertiaryProvider::class, ])->thenReturn();
Async Execution
For long-running operations, you may dispatch an attempt to run asynchronously on the queue:
$pendingResult = Attempt::try(LongRunningTask::class, $data) ->retry(3) ->async() ->onQueue('processing') ->dispatch(); // Wait for result when ready $result = $pendingResult->await();
Working with Results
The AttemptResult Object
When you call the run method instead of thenReturn, you receive an AttemptResult object that provides detailed information about the attempt:
$result = Attempt::try($callable)->run(); // Check status $result->succeeded(); // bool $result->failed(); // bool // Get values $result->value(); // mixed - the result value $result->exception(); // ?Throwable - the exception if failed $result->attempts(); // int - number of attempts made $result->resolvedBy(); // string - 'primary', 'retry:2', 'fallback:ClassName'
Monadic Operations
The AttemptResult object supports monadic operations for functional-style programming:
$result->map(fn($value) => transform($value)); $result->getOrElse('default'); $result->getOrThrow(); $result->onSuccess(fn($value) => doSomething($value)); $result->onFailure(fn($e) => handleError($e));
Events
Attempt dispatches events throughout the attempt lifecycle, allowing you to hook into various stages for logging, monitoring, or other purposes:
| Event | When Fired |
|---|---|
AttemptStarted |
When the attempt begins |
AttemptSucceeded |
On successful completion |
AttemptFailed |
On each failure (before retry) |
RetryAttempted |
When a retry is initiated |
FallbackTriggered |
When a fallback is tried |
AllAttemptsFailed |
When all attempts and fallbacks fail |
If you need to disable events for a specific attempt, use the withoutEvents method:
Attempt::try($callable) ->withoutEvents() ->thenReturn();
Testing
Attempt includes a convenient fake implementation for testing. Use the fake method to replace the Attempt facade with a test double:
use Yannelli\Attempt\Facades\Attempt; it('retries on failure', function () { Attempt::fake()->sequence([ new ConnectionException('Failed'), new ConnectionException('Failed'), ['success' => true], ]); $result = Attempt::try(MyApiCall::class) ->retry(3) ->run(); expect($result->succeeded())->toBeTrue(); expect($result->attempts())->toBe(3); Attempt::assertAttemptedTimes(MyApiCall::class, 3); }); it('uses fallback when all retries fail', function () { Attempt::fake()->failFor(PrimaryApi::class, times: 5); $result = Attempt::try(PrimaryApi::class) ->retry(3) ->fallback(BackupApi::class) ->run(); Attempt::assertFallbackUsed(BackupApi::class); });
To run the package’s test suite:
composer test
Configuration
The published configuration file (config/attempt.php) allows you to customize default behaviors:
return [ 'defaults' => [ 'max_retries' => 3, 'delay' => 100, 'backoff' => 'exponential', 'jitter' => 0.1, ], 'backoff_strategies' => [ 'exponential' => [...], 'linear' => [...], 'fibonacci' => [...], 'decorrelated_jitter' => [...], ], 'async' => [ 'connection' => env('ATTEMPT_QUEUE_CONNECTION'), 'queue' => env('ATTEMPT_QUEUE'), 'timeout' => 60, ], 'events' => [ 'enabled' => true, ], // Exceptions that should never trigger retries 'never_retry' => [ ValidationException::class, AuthenticationException::class, AuthorizationException::class, ModelNotFoundException::class, ], // Exceptions that should always trigger retries 'always_retry' => [ ConnectionException::class, ], ];
Changelog
Please see CHANGELOG for more information on what has changed recently.
Contributing
Please see CONTRIBUTING for details.
Security Vulnerabilities
Please review our security policy on how to report security vulnerabilities.
Credits
License
The MIT License (MIT). Please see License File for more information.