Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
88.64% covered (warning)
88.64%
39 / 44
54.55% covered (warning)
54.55%
6 / 11
CRAP
0.00% covered (danger)
0.00%
0 / 1
WebmailCircuitBreakerService
88.37% covered (warning)
88.37%
38 / 43
54.55% covered (warning)
54.55%
6 / 11
21.69
0.00% covered (danger)
0.00%
0 / 1
 clearMemoryState
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 isAvailable
83.33% covered (warning)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
3.04
 recordSuccess
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 recordFailure
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
2
 getStatus
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
3.14
 getLastError
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 reset
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 resetAll
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getState
90.00% covered (success)
90.00%
9 / 10
0.00% covered (danger)
0.00%
0 / 1
4.02
 saveState
87.50% covered (warning)
87.50%
7 / 8
0.00% covered (danger)
0.00%
0 / 1
3.02
1<?php
2
3declare(strict_types=1);
4
5/** @license For full copyright and license information, please see the LICENSE.md file. */
6
7namespace App\Modules\Mail\Application\Service;
8
9defined('AMMONLY_APP') || exit('Direct script access is forbidden.');
10
11use Throwable;
12use Yiisoft\Cache\CacheInterface;
13
14/**
15 * Webmail Mailbox Circuit Breaker and Failure Isolation Service.
16 *
17 * Prevents UI freezes and network cascading delays by temporarily tripping
18 * unreachable or misconfigured mailboxes into offline/cooldown state.
19 *
20 * @package App\Modules\Mail\Application\Service
21 */
22final class WebmailCircuitBreakerService
23{
24    public const string STATUS_HEALTHY = 'healthy';
25    public const string STATUS_DEGRADED = 'degraded';
26    public const string STATUS_OFFLINE = 'offline';
27
28    private const int DEFAULT_FAILURE_THRESHOLD = 2;
29    private const int DEFAULT_COOLDOWN_SECONDS = 180; // 3 minutes
30    private const string CACHE_PREFIX = 'webmail_breaker_';
31
32    /** @var array<int, array{failures: int, status: string, error: ?string, tripped_at: int}> In-memory state */
33    private static array $memoryState = [];
34
35    /**
36     * Clears in-memory circuit breaker state across all mailboxes.
37     */
38    public static function clearMemoryState(): void
39    {
40        self::$memoryState = [];
41    }
42
43    /**
44     * WebmailCircuitBreakerService constructor.
45     *
46     * @param CacheInterface|null $cache Optional cache implementation.
47     * @param int                 $failureThreshold Number of consecutive failures before tripping.
48     * @param int                 $cooldownSeconds Cooldown duration in seconds.
49     */
50    public function __construct(
51        private readonly ?CacheInterface $cache = null,
52        private readonly int $failureThreshold = self::DEFAULT_FAILURE_THRESHOLD,
53        private readonly int $cooldownSeconds = self::DEFAULT_COOLDOWN_SECONDS
54    ) {
55    }
56
57    /**
58     * Checks if the given mailbox is available for network operations.
59     *
60     * @param int $mailboxId Mailbox primary key.
61     * @return bool True if mailbox can be queried, false if tripped into cooldown.
62     */
63    public function isAvailable(int $mailboxId): bool
64    {
65        $state = $this->getState($mailboxId);
66
67        if ($state['status'] !== self::STATUS_OFFLINE) {
68            return true;
69        }
70
71        // Check if cooldown period has elapsed
72        if (time() - $state['tripped_at'] >= $this->cooldownSeconds) {
73            // Half-open: allow one retry attempt
74            return true;
75        }
76
77        return false;
78    }
79
80    /**
81     * Records a successful network operation, resetting failure counters.
82     *
83     * @param int $mailboxId Mailbox primary key.
84     */
85    public function recordSuccess(int $mailboxId): void
86    {
87        $state = [
88            'failures'   => 0,
89            'status'     => self::STATUS_HEALTHY,
90            'error'      => null,
91            'tripped_at' => 0,
92        ];
93
94        $this->saveState($mailboxId, $state);
95    }
96
97    /**
98     * Records a failed network operation, incrementing counter and tripping if threshold met.
99     *
100     * @param int    $mailboxId     Mailbox primary key.
101     * @param string $errorMessage  Diagnostic error message.
102     */
103    public function recordFailure(int $mailboxId, string $errorMessage): void
104    {
105        $state = $this->getState($mailboxId);
106        $state['failures']++;
107        $state['error'] = $errorMessage;
108
109        if ($state['failures'] >= $this->failureThreshold) {
110            $state['status'] = self::STATUS_OFFLINE;
111            $state['tripped_at'] = time();
112        } else {
113            $state['status'] = self::STATUS_DEGRADED;
114        }
115
116        $this->saveState($mailboxId, $state);
117    }
118
119    /**
120     * Retrieves current circuit status for mailbox ('healthy', 'degraded', 'offline').
121     *
122     * @param int $mailboxId Mailbox primary key.
123     * @return string Status identifier.
124     */
125    public function getStatus(int $mailboxId): string
126    {
127        $state = $this->getState($mailboxId);
128
129        if ($state['status'] === self::STATUS_OFFLINE && time() - $state['tripped_at'] >= $this->cooldownSeconds) {
130            return self::STATUS_DEGRADED;
131        }
132
133        return $state['status'];
134    }
135
136    /**
137     * Retrieves the last error message recorded for the mailbox.
138     *
139     * @param int $mailboxId Mailbox primary key.
140     * @return string|null Last error message or null if healthy.
141     */
142    public function getLastError(int $mailboxId): ?string
143    {
144        $state = $this->getState($mailboxId);
145        return $state['error'];
146    }
147
148    /**
149     * Explicitly resets circuit breaker state for a mailbox.
150     *
151     * @param int $mailboxId Mailbox primary key.
152     */
153    public function reset(int $mailboxId): void
154    {
155        $this->recordSuccess($mailboxId);
156    }
157
158    /**
159     * Resets all in-memory circuit breaker states (useful for testing and flush).
160     */
161    public static function resetAll(): void
162    {
163        self::$memoryState = [];
164    }
165
166    /**
167     * Resolves mailbox circuit state from cache or local memory.
168     *
169     * @param int $mailboxId Mailbox ID.
170     * @return array{failures: int, status: string, error: ?string, tripped_at: int} Circuit state.
171     */
172    private function getState(int $mailboxId): array
173    {
174        if ($this->cache !== null) {
175            try {
176                $cached = $this->cache->psr()->get(self::CACHE_PREFIX . $mailboxId);
177                if (is_array($cached)) {
178                    return $cached;
179                }
180            } catch (Throwable) {
181                // Fallback to static memory state
182            }
183        }
184
185        return self::$memoryState[$mailboxId] ?? [
186            'failures'   => 0,
187            'status'     => self::STATUS_HEALTHY,
188            'error'      => null,
189            'tripped_at' => 0,
190        ];
191    }
192
193    /**
194     * Persists circuit state into cache and memory.
195     *
196     * @param int $mailboxId Mailbox ID.
197     * @param array{failures: int, status: string, error: ?string, tripped_at: int} $state
198     */
199    private function saveState(int $mailboxId, array $state): void
200    {
201        self::$memoryState[$mailboxId] = $state;
202
203        if ($this->cache !== null) {
204            try {
205                $this->cache->psr()->set(
206                    self::CACHE_PREFIX . $mailboxId,
207                    $state,
208                    $this->cooldownSeconds * 2
209                );
210            } catch (Throwable) {
211                // Ignore cache persistence errors
212            }
213        }
214    }
215}