Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ jobs:
fail-fast: false
matrix:
os: [ubuntu-latest]
php: [8.5, 8.4, 8.3, 8.2, 8.1, 8.0, 7.4, 7.3]
php: [8.5, 8.4, 8.3, 8.2, 8.1, 8.0, 7.4]
dependency-version: [prefer-lowest, prefer-stable]

name: P${{ matrix.php }} - ${{ matrix.dependency-version }} - ${{ matrix.os }}
Expand Down
37 changes: 37 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,43 @@ $optimizerChain

In this example each optimizer in the chain will get a maximum 10 seconds to do it's job.

### Handling errors

By default, when an optimizer fails (for example its binary exits with a non-zero status), the failure is logged and the chain simply continues with the next optimizer. If you'd rather be notified, or abort the whole chain, use `throws`.

Call `throws()` to make the chain rethrow the failure and stop:

```php
$optimizerChain
->throws()
->optimize($pathToImage);
```

Pass `false` to keep the default "log and continue" behaviour explicitly:

```php
$optimizerChain
->throws(false)
->optimize($pathToImage);
```

Or pass a callable to decide for yourself. The handler receives the exception, the optimizer that failed and the image being optimized. Return to continue with the next optimizer, or throw to abort the chain:

```php
use Spatie\ImageOptimizer\Image;
use Spatie\ImageOptimizer\Optimizer;

$optimizerChain
->throws(function (Throwable $exception, Optimizer $optimizer, Image $image) {
report($exception);

// return to continue the chain, or throw to abort it
})
->optimize($pathToImage);
```

Any exception raised while applying an optimizer, most notably a `ProcessTimedOutException` when an optimizer exceeds its [timeout](#setting-a-timeout), flows through this same mechanism. With the default (or `throws(false)`) it is caught and the chain continues; with `throws()` or a callable you get to inspect and decide what to do with it.

### Creating your own optimization chains

If you want to customize the chain of optimizers you can do so by adding `Optimizer`s manually to an `OptimizerChain`.
Expand Down
2 changes: 1 addition & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
}
],
"require": {
"php": "^7.3|^8.0",
"php": "^7.4|^8.0",
"ext-fileinfo": "*",
"psr/log": "^1.0 | ^2.0 | ^3.0",
"symfony/process": "^4.2|^5.0|^6.0|^7.0|^8.0"
Expand Down
77 changes: 70 additions & 7 deletions src/OptimizerChain.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@

use InvalidArgumentException;
use Psr\Log\LoggerInterface;
use Symfony\Component\Process\Exception\ProcessFailedException;
use Symfony\Component\Process\Process;
use Throwable;

class OptimizerChain
{
Expand All @@ -17,6 +19,9 @@ class OptimizerChain
/** @var int */
protected $timeout = 60;

/** @var callable|null */
protected $errorHandler;

public function __construct()
{
$this->useLogger(new DummyLogger());
Expand Down Expand Up @@ -62,6 +67,44 @@ public function useLogger(LoggerInterface $log)
return $this;
}

/*
* Control what happens when an optimizer fails. By default (when this is never called) a
* failing optimizer is logged and the chain continues. Pass true to instead rethrow and
* abort the chain, false to keep the default swallow-and-continue, or a callable that
* receives the exception, the optimizer and the image; return from it to continue or
* throw to abort.
*
* Exceptions that were previously thrown straight out of optimize() - such as a
* ProcessTimedOutException when an optimizer exceeds its timeout - now flow through here
* too, so they are caught by default and can be inspected or rethrown via this handler.
*
* This only covers failures while applying an optimizer. The "Cannot copy file" exception
* thrown by optimize() when the output file cannot be written happens before any optimizer
* runs, so it always bubbles up regardless of this setting.
*/
public function throws($handler = true)
{
if (! is_bool($handler) && ! is_callable($handler)) {
throw new InvalidArgumentException('The handler passed to `throws()` must be a boolean or a callable.');
}

if ($handler === false) {
$this->errorHandler = null;

return $this;
}

if ($handler === true) {
$handler = function (Throwable $exception) {
throw $exception;
};
}

$this->errorHandler = $handler;

return $this;
}

public function optimize(string $pathToImage, ?string $pathToOutput = null)
{
if ($pathToOutput) {
Expand Down Expand Up @@ -91,6 +134,22 @@ protected function applyOptimizer(Optimizer $optimizer, Image $image)

$optimizer->setImagePath($image->path());

try {
$this->runOptimizer($optimizer, $image);
} catch (Throwable $exception) {
$this->handleException($exception, $optimizer, $image);
} finally {
if (
($tmpPath = $optimizer->getTmpPath()) &&
file_exists($tmpPath)
) {
unlink($tmpPath);
}
}
}

protected function runOptimizer(Optimizer $optimizer, Image $image)
{
$command = $optimizer->getCommand();

$this->logger->info("Executing `{$command}`");
Expand All @@ -101,14 +160,11 @@ protected function applyOptimizer(Optimizer $optimizer, Image $image)
->setTimeout($this->timeout)
->run();

if (
($tmpPath = $optimizer->getTmpPath()) &&
file_exists($tmpPath)
) {
unlink($tmpPath);
}

$this->logResult($process);

if (! $process->isSuccessful()) {
throw new ProcessFailedException($process);
}
}

protected function logResult(Process $process)
Expand All @@ -121,4 +177,11 @@ protected function logResult(Process $process)

$this->logger->info("Process successfully ended with output `{$process->getOutput()}`");
}

protected function handleException(Throwable $exception, Optimizer $optimizer, Image $image)
{
if ($this->errorHandler) {
($this->errorHandler)($exception, $optimizer, $image);
}
}
}
Loading