Skip to content

Commit 5148611

Browse files
authored
Merge pull request #11 from Button99/main
add Artisan commands for Fuse circuit management
2 parents 73c1e43 + 12ce95e commit 5148611

File tree

8 files changed

+365
-1
lines changed

8 files changed

+365
-1
lines changed

README.md

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -340,6 +340,46 @@ Gate::define('viewFuse', function ($user = null) {
340340

341341
---
342342

343+
## Artisan Commands
344+
345+
Fuse includes CLI commands for inspecting and manually controlling circuit breakers.
346+
347+
### Check circuit status
348+
349+
```bash
350+
php artisan fuse:status # all services
351+
php artisan fuse:status stripe # single service
352+
```
353+
354+
Outputs a table with the current state, failure rate, request counts, and threshold for each circuit.
355+
356+
### Reset a circuit
357+
358+
```bash
359+
php artisan fuse:reset # all services
360+
php artisan fuse:reset stripe # single service
361+
```
362+
363+
Resets the circuit to CLOSED state and clears all stats for the current window.
364+
365+
### Manually open a circuit
366+
367+
```bash
368+
php artisan fuse:open stripe
369+
```
370+
371+
Forces the circuit OPEN immediately. Useful when you know a service is down and want to protect your queue before failures accumulate. The circuit will recover automatically after the configured `timeout`.
372+
373+
### Manually close a circuit
374+
375+
```bash
376+
php artisan fuse:close stripe
377+
```
378+
379+
Forces the circuit CLOSED immediately. Useful when a service has recovered but the circuit hasn't timed out yet.
380+
381+
---
382+
343383
## Fallback Strategies
344384

345385
When the circuit opens, your application needs a plan. Here are common strategies:

src/CircuitBreaker.php

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,22 @@ public function reset(): void
171171
Cache::lock($this->key('transition'))->forceRelease();
172172
}
173173

174+
public function forceOpen(): void
175+
{
176+
Cache::put($this->key('state'), CircuitState::Open->value);
177+
Cache::put($this->key('opened_at'), time());
178+
179+
event(new CircuitBreakerOpened($this->serviceName));
180+
}
181+
182+
public function forceClose(): void
183+
{
184+
Cache::put($this->key('state'), CircuitState::Closed->value);
185+
Cache::forget($this->key('opened_at'));
186+
187+
event(new CircuitBreakerClosed($this->serviceName));
188+
}
189+
174190
private function transitionTo(
175191
CircuitState $newState,
176192
float $failureRate = 0,

src/Commands/FuseCloseCommand.php

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
<?php
2+
3+
namespace Harris21\Fuse\Commands;
4+
5+
use Harris21\Fuse\CircuitBreaker;
6+
use Illuminate\Console\Command;
7+
8+
class FuseCloseCommand extends Command
9+
{
10+
protected $signature = 'fuse:close {service}';
11+
12+
protected $description = 'Manually close circuit breaker';
13+
14+
public function handle(): int
15+
{
16+
$service = $this->argument('service');
17+
18+
if ($service && ! array_key_exists($service, config('fuse.services', []))) {
19+
$this->warn("Service '{$service}' is not configured in config/fuse.php");
20+
21+
return self::SUCCESS;
22+
}
23+
24+
(new CircuitBreaker($service))->forceClose();
25+
26+
$this->info("Circuit breaker for {$service} has been manually closed.");
27+
28+
return self::SUCCESS;
29+
}
30+
}

src/Commands/FuseOpenCommand.php

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
<?php
2+
3+
namespace Harris21\Fuse\Commands;
4+
5+
use Harris21\Fuse\CircuitBreaker;
6+
use Illuminate\Console\Command;
7+
8+
class FuseOpenCommand extends Command
9+
{
10+
protected $signature = 'fuse:open {service}';
11+
12+
protected $description = 'Manually open circuit breaker';
13+
14+
public function handle(): int
15+
{
16+
$service = $this->argument('service');
17+
18+
if ($service && ! array_key_exists($service, config('fuse.services', []))) {
19+
$this->warn("Service '{$service}' is not configured in config/fuse.php");
20+
21+
return self::SUCCESS;
22+
}
23+
24+
(new CircuitBreaker($service))->forceOpen();
25+
26+
$this->info("Circuit breaker for {$service} has been manually opened.");
27+
28+
return self::SUCCESS;
29+
}
30+
}

src/Commands/FuseResetCommand.php

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
<?php
2+
3+
namespace Harris21\Fuse\Commands;
4+
5+
use Harris21\Fuse\CircuitBreaker;
6+
use Illuminate\Console\Command;
7+
8+
class FuseResetCommand extends Command
9+
{
10+
protected $signature = 'fuse:reset {service?}';
11+
12+
protected $description = 'Reset circuit breakers to closed state';
13+
14+
public function handle(): int
15+
{
16+
$services = $this->argument('service')
17+
? [$this->argument('service')]
18+
: array_keys(config('fuse.services', []));
19+
20+
if ($this->argument('service') && ! array_key_exists($this->argument('service'), config('fuse.services', []))) {
21+
$this->warn("Service '{$this->argument('service')}' is not configured in config/fuse.php");
22+
23+
return self::SUCCESS;
24+
}
25+
26+
if (empty($services)) {
27+
$this->warn('No services configured in config/fuse.php');
28+
29+
return self::SUCCESS;
30+
}
31+
32+
foreach ($services as $service) {
33+
(new CircuitBreaker($service))->reset();
34+
35+
$this->info("Circuit breaker {$service} has been reset to closed state");
36+
}
37+
38+
return self::SUCCESS;
39+
}
40+
}

src/Commands/FuseStatusCommand.php

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
<?php
2+
3+
namespace Harris21\Fuse\Commands;
4+
5+
use Harris21\Fuse\CircuitBreaker;
6+
use Illuminate\Console\Command;
7+
8+
class FuseStatusCommand extends Command
9+
{
10+
protected $signature = 'fuse:status {service?}';
11+
12+
protected $description = 'Display the status of circuit breakers';
13+
14+
public function handle(): int
15+
{
16+
$services = $this->argument('service')
17+
? [$this->argument('service')]
18+
: array_keys(config('fuse.services', []));
19+
20+
if ($this->argument('service') && ! array_key_exists($this->argument('service'), config('fuse.services', []))) {
21+
$this->warn("Service '{$this->argument('service')}' is not configured in config/fuse.php");
22+
23+
return self::SUCCESS;
24+
}
25+
26+
if (empty($services)) {
27+
$this->warn('No services configured in config/fuse.php');
28+
29+
return self::SUCCESS;
30+
}
31+
32+
$rows = [];
33+
foreach ($services as $service) {
34+
$breaker = new CircuitBreaker($service);
35+
$stats = $breaker->getStats();
36+
37+
$state = match (true) {
38+
$breaker->isOpen() => '<fg=red>OPEN</>',
39+
$breaker->isHalfOpen() => '<fg=yellow>HALF-OPEN</>',
40+
default => '<fg=green>CLOSED</>',
41+
};
42+
43+
$rows[] = [
44+
$service,
45+
$state,
46+
number_format($stats['failure_rate'], 1).'%',
47+
$stats['attempts'],
48+
$stats['failures'],
49+
$stats['threshold'].'%',
50+
];
51+
}
52+
53+
$this->table(['Service', 'State', 'Failure Rate', 'Requests', 'Failures', 'Threshold'], $rows);
54+
55+
return self::SUCCESS;
56+
}
57+
}

src/FuseServiceProvider.php

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,10 @@
22

33
namespace Harris21\Fuse;
44

5+
use Harris21\Fuse\Commands\FuseCloseCommand;
6+
use Harris21\Fuse\Commands\FuseOpenCommand;
7+
use Harris21\Fuse\Commands\FuseResetCommand;
8+
use Harris21\Fuse\Commands\FuseStatusCommand;
59
use Illuminate\Contracts\Auth\Access\Gate as GateContract;
610
use Spatie\LaravelPackageTools\Package;
711
use Spatie\LaravelPackageTools\PackageServiceProvider;
@@ -14,7 +18,13 @@ public function configurePackage(Package $package): void
1418
->name('fuse')
1519
->hasConfigFile()
1620
->hasViews()
17-
->hasRoute('web');
21+
->hasRoute('web')
22+
->hasCommands([
23+
FuseStatusCommand::class,
24+
FuseResetCommand::class,
25+
FuseOpenCommand::class,
26+
FuseCloseCommand::class,
27+
]);
1828
}
1929

2030
public function packageBooted(): void
Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
<?php
2+
3+
use Harris21\Fuse\CircuitBreaker;
4+
use Harris21\Fuse\Events\CircuitBreakerClosed;
5+
use Harris21\Fuse\Events\CircuitBreakerOpened;
6+
use Illuminate\Support\Facades\Cache;
7+
use Illuminate\Support\Facades\Event;
8+
9+
beforeEach(function () {
10+
Cache::flush();
11+
config(['fuse.services' => [
12+
'stripe' => ['threshold' => 50, 'min_requests' => 5],
13+
'mailgun' => ['threshold' => 50, 'min_requests' => 5],
14+
]]);
15+
});
16+
17+
it('displays status of all configured services', function () {
18+
$this->artisan('fuse:status')
19+
->assertExitCode(0);
20+
});
21+
22+
it('displays status of a specific service', function () {
23+
$this->artisan('fuse:status stripe')
24+
->assertExitCode(0);
25+
});
26+
27+
it('warns when no services are configured', function () {
28+
config(['fuse.services' => []]);
29+
30+
$this->artisan('fuse:status')
31+
->expectsOutput('No services configured in config/fuse.php')
32+
->assertExitCode(0);
33+
});
34+
35+
it('warns when service is not configured', function () {
36+
$this->artisan('fuse:status unknown-service')
37+
->expectsOutput("Service 'unknown-service' is not configured in config/fuse.php")
38+
->assertExitCode(0);
39+
});
40+
41+
it('warns when service is not configured in fuse:reset', function () {
42+
$this->artisan('fuse:reset unknown-service')
43+
->expectsOutput("Service 'unknown-service' is not configured in config/fuse.php")
44+
->assertExitCode(0);
45+
});
46+
47+
it('warns when service is not configured in fuse:open', function () {
48+
$this->artisan('fuse:open unknown-service')
49+
->expectsOutput("Service 'unknown-service' is not configured in config/fuse.php")
50+
->assertExitCode(0);
51+
});
52+
53+
it('warns when service is not configured in fuse:close', function () {
54+
$this->artisan('fuse:close unknown-service')
55+
->expectsOutput("Service 'unknown-service' is not configured in config/fuse.php")
56+
->assertExitCode(0);
57+
});
58+
59+
it('resets a specific circuit breaker', function () {
60+
$breaker = new CircuitBreaker('stripe');
61+
for ($i = 0; $i < 5; $i++) {
62+
$breaker->recordFailure();
63+
}
64+
65+
expect($breaker->isOpen())->toBeTrue();
66+
67+
$this->artisan('fuse:reset stripe')
68+
->assertExitCode(0);
69+
70+
expect($breaker->isClosed())->toBeTrue();
71+
});
72+
73+
it('resets all circuit breakers when no service is provided', function () {
74+
$stripe = new CircuitBreaker('stripe');
75+
$mailgun = new CircuitBreaker('mailgun');
76+
for ($i = 0; $i < 5; $i++) {
77+
$stripe->recordFailure();
78+
$mailgun->recordFailure();
79+
}
80+
expect($stripe->isOpen())->toBeTrue()
81+
->and($mailgun->isOpen())->toBeTrue();
82+
83+
$this->artisan('fuse:reset')
84+
->assertExitCode(0);
85+
86+
expect($stripe->isClosed())->toBeTrue()
87+
->and($mailgun->isClosed())->toBeTrue();
88+
});
89+
90+
it('warns when no services are configured for reset', function () {
91+
config(['fuse.services' => []]);
92+
$this->artisan('fuse:reset')
93+
->expectsOutput('No services configured in config/fuse.php')
94+
->assertExitCode(0);
95+
});
96+
97+
it('manually opens a circuit breaker', function () {
98+
$breaker = new CircuitBreaker('stripe');
99+
expect($breaker->isClosed())->toBeTrue();
100+
101+
$this->artisan('fuse:open stripe')
102+
->assertExitCode(0);
103+
104+
expect($breaker->isOpen())->toBeTrue();
105+
});
106+
107+
it('dispatches CircuitBreakerOpened event when force opening', function () {
108+
Event::fake([CircuitBreakerOpened::class]);
109+
110+
$this->artisan('fuse:open stripe')
111+
->assertExitCode(0);
112+
113+
Event::assertDispatched(CircuitBreakerOpened::class, fn ($event) => $event->service === 'stripe');
114+
});
115+
116+
it('manually closes a circuit breaker', function () {
117+
$breaker = new CircuitBreaker('stripe');
118+
for ($i = 0; $i < 5; $i++) {
119+
$breaker->recordFailure();
120+
}
121+
expect($breaker->isOpen())->toBeTrue();
122+
123+
$this->artisan('fuse:close stripe')
124+
->assertExitCode(0);
125+
126+
expect($breaker->isClosed())->toBeTrue();
127+
});
128+
129+
it('dispatches CircuitBreakerClosed event when force closing', function () {
130+
Event::fake([CircuitBreakerClosed::class]);
131+
132+
$breaker = new CircuitBreaker('stripe');
133+
for ($i = 0; $i < 5; $i++) {
134+
$breaker->recordFailure();
135+
}
136+
137+
$this->artisan('fuse:close stripe')
138+
->assertExitCode(0);
139+
140+
Event::assertDispatched(CircuitBreakerClosed::class, fn ($event) => $event->service === 'stripe');
141+
});

0 commit comments

Comments
 (0)