Skip to content

Commit bdad072

Browse files
authored
Fallback to http request when checking for internet connection (alexjustesen#2685)
Co-authored-by: Alex Justesen <[email protected]>
1 parent 49b77d3 commit bdad072

File tree

3 files changed

+220
-7
lines changed

3 files changed

+220
-7
lines changed

app/Actions/PingHostname.php

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,22 +6,36 @@
66
use Lorisleiva\Actions\Concerns\AsAction;
77
use Spatie\Ping\Ping;
88
use Spatie\Ping\PingResult;
9+
use Throwable;
910

1011
class PingHostname
1112
{
1213
use AsAction;
1314

14-
public function handle(?string $hostname = null, int $count = 1): PingResult
15+
/**
16+
* Attempt to ping the given hostname. Returns null when the ping binary
17+
* is unavailable or another OS-level error prevents execution.
18+
*/
19+
public function handle(?string $hostname = null, int $count = 1): ?PingResult
1520
{
1621
$hostname = $hostname ?? config('speedtest.preflight.internet_check_hostname');
1722

1823
// Remove protocol if present
1924
$hostname = preg_replace('#^https?://#', '', $hostname);
2025

21-
$ping = (new Ping(
22-
hostname: $hostname,
23-
count: $count,
24-
))->run();
26+
try {
27+
$ping = (new Ping(
28+
hostname: $hostname,
29+
count: $count,
30+
))->run();
31+
} catch (Throwable $e) {
32+
Log::debug('Ping command unavailable', [
33+
'host' => $hostname,
34+
'error' => $e->getMessage(),
35+
]);
36+
37+
return null;
38+
}
2539

2640
$data = $ping->toArray();
2741
unset($data['raw_output'], $data['lines']);

app/Jobs/CheckForInternetConnectionJob.php

Lines changed: 49 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,9 @@
1111
use Illuminate\Contracts\Queue\ShouldQueue;
1212
use Illuminate\Foundation\Queue\Queueable;
1313
use Illuminate\Queue\Middleware\SkipIfBatchCancelled;
14+
use Illuminate\Support\Facades\Http;
15+
use Illuminate\Support\Facades\Log;
16+
use Throwable;
1417

1518
class CheckForInternetConnectionJob implements ShouldQueue
1619
{
@@ -46,11 +49,20 @@ public function handle(): void
4649

4750
$ping = PingHostname::run();
4851

49-
if ($ping->isSuccess()) {
52+
if ($ping?->isSuccess()) {
5053
return;
5154
}
5255

53-
$message = sprintf('Failed to connected to hostname "%s". Error received "%s".', $ping->getHost(), $ping->error()?->value);
56+
Log::debug('Pinged failed, falling back to HTTP connectivity check');
57+
58+
// Ping either failed or was unavailable — attempt an HTTP fallback.
59+
if ($this->httpFallbackSucceeds()) {
60+
return;
61+
}
62+
63+
$message = $ping === null
64+
? 'Ping command is unavailable and HTTP fallback also failed.'
65+
: sprintf('Failed to connected to hostname "%s". Error received "%s". HTTP fallback also failed.', $ping->getHost(), $ping->error()?->value);
5466

5567
$this->result->update([
5668
'data->type' => 'log',
@@ -63,4 +75,39 @@ public function handle(): void
6375

6476
$this->batch()->cancel();
6577
}
78+
79+
/**
80+
* Attempt to verify connectivity via an HTTP GET request as a fallback
81+
* when ping is unavailable or unsuccessful.
82+
*/
83+
protected function httpFallbackSucceeds(): bool
84+
{
85+
$url = config('speedtest.preflight.external_ip_url');
86+
87+
try {
88+
$response = Http::retry(3, 100)
89+
->timeout(5)
90+
->get(url: $url);
91+
92+
if ($response->ok()) {
93+
Log::debug('HTTP fallback connectivity check succeeded', ['url' => $url]);
94+
95+
return true;
96+
}
97+
98+
Log::debug('HTTP fallback connectivity check received non-OK response', [
99+
'url' => $url,
100+
'status' => $response->status(),
101+
]);
102+
103+
return false;
104+
} catch (Throwable $e) {
105+
Log::debug('HTTP fallback connectivity check failed', [
106+
'url' => $url,
107+
'error' => $e->getMessage(),
108+
]);
109+
110+
return false;
111+
}
112+
}
66113
}
Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
<?php
2+
3+
use App\Actions\PingHostname;
4+
use App\Enums\ResultStatus;
5+
use App\Events\SpeedtestFailed;
6+
use App\Jobs\CheckForInternetConnectionJob;
7+
use App\Models\Result;
8+
use Illuminate\Support\Facades\Event;
9+
use Illuminate\Support\Facades\Http;
10+
use Spatie\Ping\PingResult;
11+
12+
beforeEach(function () {
13+
Event::fake();
14+
});
15+
16+
describe('CheckForInternetConnectionJob', function () {
17+
test('batch continues when ping succeeds', function () {
18+
$result = Result::factory()->create(['status' => ResultStatus::Started]);
19+
20+
$successfulPing = PingResult::fromArray(['success' => true, 'host' => 'icanhazip.com']);
21+
app()->bind(PingHostname::class, fn () => new class($successfulPing)
22+
{
23+
public function __construct(private PingResult $ping) {}
24+
25+
public function handle(?string $hostname = null, int $count = 1): ?PingResult
26+
{
27+
return $this->ping;
28+
}
29+
});
30+
31+
[$job, $batch] = (new CheckForInternetConnectionJob($result))->withFakeBatch();
32+
$job->handle();
33+
34+
$this->assertFalse($batch->cancelled());
35+
$result->refresh();
36+
expect($result->status)->toBe(ResultStatus::Checking);
37+
Event::assertNotDispatched(SpeedtestFailed::class);
38+
});
39+
40+
test('batch continues when ping fails but HTTP fallback succeeds', function () {
41+
$result = Result::factory()->create(['status' => ResultStatus::Started]);
42+
43+
$failedPing = PingResult::fromArray([
44+
'success' => false,
45+
'error' => 'hostUnreachable',
46+
'host' => 'icanhazip.com',
47+
]);
48+
app()->bind(PingHostname::class, fn () => new class($failedPing)
49+
{
50+
public function __construct(private PingResult $ping) {}
51+
52+
public function handle(?string $hostname = null, int $count = 1): ?PingResult
53+
{
54+
return $this->ping;
55+
}
56+
});
57+
58+
Http::fake([
59+
'*' => Http::response('1.2.3.4', 200),
60+
]);
61+
62+
[$job, $batch] = (new CheckForInternetConnectionJob($result))->withFakeBatch();
63+
$job->handle();
64+
65+
$this->assertFalse($batch->cancelled());
66+
$result->refresh();
67+
expect($result->status)->toBe(ResultStatus::Checking);
68+
Event::assertNotDispatched(SpeedtestFailed::class);
69+
});
70+
71+
test('batch continues when ping is unavailable but HTTP fallback succeeds', function () {
72+
$result = Result::factory()->create(['status' => ResultStatus::Started]);
73+
74+
app()->bind(PingHostname::class, fn () => new class
75+
{
76+
public function handle(?string $hostname = null, int $count = 1): ?PingResult
77+
{
78+
return null;
79+
}
80+
});
81+
82+
Http::fake([
83+
'*' => Http::response('1.2.3.4', 200),
84+
]);
85+
86+
[$job, $batch] = (new CheckForInternetConnectionJob($result))->withFakeBatch();
87+
$job->handle();
88+
89+
$this->assertFalse($batch->cancelled());
90+
$result->refresh();
91+
expect($result->status)->toBe(ResultStatus::Checking);
92+
Event::assertNotDispatched(SpeedtestFailed::class);
93+
});
94+
95+
test('batch is cancelled when ping fails and HTTP fallback also fails', function () {
96+
$result = Result::factory()->create(['status' => ResultStatus::Started]);
97+
98+
$failedPing = PingResult::fromArray([
99+
'success' => false,
100+
'error' => 'hostUnreachable',
101+
'host' => 'icanhazip.com',
102+
]);
103+
app()->bind(PingHostname::class, fn () => new class($failedPing)
104+
{
105+
public function __construct(private PingResult $ping) {}
106+
107+
public function handle(?string $hostname = null, int $count = 1): ?PingResult
108+
{
109+
return $this->ping;
110+
}
111+
});
112+
113+
Http::fake([
114+
'*' => Http::response('Service Unavailable', 503),
115+
]);
116+
117+
[$job, $batch] = (new CheckForInternetConnectionJob($result))->withFakeBatch();
118+
$job->handle();
119+
120+
$this->assertTrue($batch->cancelled());
121+
$result->refresh();
122+
expect($result->status)->toBe(ResultStatus::Failed);
123+
expect($result->data['level'])->toBe('error');
124+
expect($result->data['message'])->toContain('HTTP fallback also failed');
125+
Event::assertDispatched(SpeedtestFailed::class);
126+
});
127+
128+
test('batch is cancelled when ping is unavailable and HTTP fallback throws', function () {
129+
$result = Result::factory()->create(['status' => ResultStatus::Started]);
130+
131+
app()->bind(PingHostname::class, fn () => new class
132+
{
133+
public function handle(?string $hostname = null, int $count = 1): ?PingResult
134+
{
135+
return null;
136+
}
137+
});
138+
139+
Http::fake([
140+
'*' => Http::failedConnection(),
141+
]);
142+
143+
[$job, $batch] = (new CheckForInternetConnectionJob($result))->withFakeBatch();
144+
$job->handle();
145+
146+
$this->assertTrue($batch->cancelled());
147+
$result->refresh();
148+
expect($result->status)->toBe(ResultStatus::Failed);
149+
expect($result->data['message'])->toBe('Ping command is unavailable and HTTP fallback also failed.');
150+
Event::assertDispatched(SpeedtestFailed::class);
151+
});
152+
});

0 commit comments

Comments
 (0)