Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
93.75% covered (success)
93.75%
30 / 32
85.71% covered (warning)
85.71%
6 / 7
CRAP
0.00% covered (danger)
0.00%
0 / 1
RetryingIcapClient
93.75% covered (success)
93.75%
30 / 32
85.71% covered (warning)
85.71%
6 / 7
15.05
0.00% covered (danger)
0.00%
0 / 1
 __construct
83.33% covered (warning)
83.33%
10 / 12
0.00% covered (danger)
0.00%
0 / 1
6.17
 request
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 options
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 scanFile
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 scanFileWithPreview
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 withRetry
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
4
 backoffFor
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3/**
4 * SPDX-License-Identifier: EUPL-1.2
5 *
6 * This file is part of icap-flow.
7 *
8 * Licensed under the EUPL, Version 1.2 only (the "Licence");
9 * you may not use this work except in compliance with the Licence.
10 * You may obtain a copy of the Licence at:
11 *
12 *     https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
13 *
14 * Unless required by applicable law or agreed to in writing, software
15 * distributed under the Licence is distributed on an "AS IS" basis,
16 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
17 */
18
19declare(strict_types=1);
20
21namespace Ndrstmr\Icap;
22
23use Amp\Cancellation;
24use Amp\Future;
25use Closure;
26use Ndrstmr\Icap\DTO\IcapRequest;
27use Ndrstmr\Icap\Exception\IcapServerException;
28
29use function Amp\delay;
30
31/**
32 * Retry decorator around any {@see IcapClientInterface}.
33 *
34 * Retries only on {@see IcapServerException} (ICAP 5xx) — those are
35 * transient by RFC 3507 §4.3.3. Client errors (4xx, parse failures,
36 * connection errors) propagate immediately; retrying them would either
37 * be useless (the request is malformed) or compound a hostile
38 * environment problem (network is down).
39 *
40 * Backoff is exponential: `baseDelaySeconds * backoffFactor^(attempt-1)`,
41 * capped at `maxDelaySeconds`. With the defaults (100 ms / 2x / cap 5 s)
42 * three retries wait 0.1 / 0.2 / 0.4 s.
43 *
44 * Example wiring:
45 *
46 *     $client = new RetryingIcapClient(
47 *         IcapClient::create(),
48 *         maxAttempts: 3,
49 *         baseDelaySeconds: 0.1,
50 *         maxDelaySeconds: 5.0,
51 *     );
52 */
53final class RetryingIcapClient implements IcapClientInterface
54{
55    /** @var Closure(float): void */
56    private Closure $sleeper;
57
58    /**
59     * @param IcapClientInterface           $inner            Wrapped client (typically IcapClient)
60     * @param int                           $maxAttempts      Maximum total attempts (initial + retries); must be >= 1
61     * @param float                         $baseDelaySeconds Initial delay before the first retry
62     * @param float                         $maxDelaySeconds  Cap on any individual retry delay
63     * @param float                         $backoffFactor    Multiplier applied between attempts
64     * @param (Closure(float): void)|null   $sleeper          Test seam — replaces Amp\delay() with a callable
65     */
66    public function __construct(
67        private IcapClientInterface $inner,
68        private int $maxAttempts = 3,
69        private float $baseDelaySeconds = 0.1,
70        private float $maxDelaySeconds = 5.0,
71        private float $backoffFactor = 2.0,
72        ?Closure $sleeper = null,
73    ) {
74        if ($maxAttempts < 1) {
75            throw new \InvalidArgumentException('maxAttempts must be >= 1, got: ' . $maxAttempts);
76        }
77        if ($baseDelaySeconds < 0.0) {
78            throw new \InvalidArgumentException('baseDelaySeconds must be >= 0, got: ' . $baseDelaySeconds);
79        }
80        if ($maxDelaySeconds < 0.0) {
81            throw new \InvalidArgumentException('maxDelaySeconds must be >= 0, got: ' . $maxDelaySeconds);
82        }
83        if ($backoffFactor <= 0.0) {
84            throw new \InvalidArgumentException('backoffFactor must be > 0, got: ' . $backoffFactor);
85        }
86
87        $this->sleeper = $sleeper ?? static function (float $seconds): void {
88            if ($seconds > 0.0) {
89                delay($seconds);
90            }
91        };
92    }
93
94    #[\Override]
95    public function request(IcapRequest $request, ?Cancellation $cancellation = null): Future
96    {
97        return $this->withRetry(fn (): Future => $this->inner->request($request, $cancellation));
98    }
99
100    #[\Override]
101    public function options(string $service, ?Cancellation $cancellation = null): Future
102    {
103        return $this->withRetry(fn (): Future => $this->inner->options($service, $cancellation));
104    }
105
106    /**
107     * @param array<string, string|string[]> $extraHeaders
108     */
109    #[\Override]
110    public function scanFile(
111        string $service,
112        string $filePath,
113        array $extraHeaders = [],
114        ?Cancellation $cancellation = null,
115    ): Future {
116        return $this->withRetry(fn (): Future => $this->inner->scanFile($service, $filePath, $extraHeaders, $cancellation));
117    }
118
119    /**
120     * @param array<string, string|string[]> $extraHeaders
121     */
122    #[\Override]
123    public function scanFileWithPreview(
124        string $service,
125        string $filePath,
126        ?int $previewSize = null,
127        array $extraHeaders = [],
128        ?Cancellation $cancellation = null,
129    ): Future {
130        return $this->withRetry(
131            fn (): Future => $this->inner->scanFileWithPreview($service, $filePath, $previewSize, $extraHeaders, $cancellation),
132        );
133    }
134
135    /**
136     * @template T
137     *
138     * @param Closure(): Future<T> $operation
139     *
140     * @return Future<T>
141     */
142    private function withRetry(Closure $operation): Future
143    {
144        /** @var Future<T> $future */
145        $future = \Amp\async(function () use ($operation): mixed {
146            // The loop runs at least once because maxAttempts >= 1 is
147            // enforced in the ctor, so the final throw always has a
148            // captured exception to re-raise.
149            /** @var IcapServerException $lastException */
150            $lastException = new IcapServerException('unreachable');
151
152            for ($attempt = 1; $attempt <= $this->maxAttempts; $attempt++) {
153                try {
154                    return $operation()->await();
155                } catch (IcapServerException $e) {
156                    $lastException = $e;
157                    if ($attempt === $this->maxAttempts) {
158                        break;
159                    }
160                    ($this->sleeper)($this->backoffFor($attempt));
161                }
162            }
163
164            throw $lastException;
165        });
166
167        return $future;
168    }
169
170    private function backoffFor(int $attempt): float
171    {
172        $delay = $this->baseDelaySeconds * ($this->backoffFactor ** ($attempt - 1));
173        return min($delay, $this->maxDelaySeconds);
174    }
175}