Fallback to http request when checking for internet connection (#2685)

Co-authored-by: Alex Justesen <1144087+alexjustesen@users.noreply.github.com>
This commit is contained in:
Alex Justesen
2026-02-04 11:10:25 -05:00
committed by GitHub
parent 49b77d3731
commit bdad072a3d
3 changed files with 220 additions and 7 deletions
+19 -5
View File
@@ -6,22 +6,36 @@ use Illuminate\Support\Facades\Log;
use Lorisleiva\Actions\Concerns\AsAction;
use Spatie\Ping\Ping;
use Spatie\Ping\PingResult;
use Throwable;
class PingHostname
{
use AsAction;
public function handle(?string $hostname = null, int $count = 1): PingResult
/**
* Attempt to ping the given hostname. Returns null when the ping binary
* is unavailable or another OS-level error prevents execution.
*/
public function handle(?string $hostname = null, int $count = 1): ?PingResult
{
$hostname = $hostname ?? config('speedtest.preflight.internet_check_hostname');
// Remove protocol if present
$hostname = preg_replace('#^https?://#', '', $hostname);
$ping = (new Ping(
hostname: $hostname,
count: $count,
))->run();
try {
$ping = (new Ping(
hostname: $hostname,
count: $count,
))->run();
} catch (Throwable $e) {
Log::debug('Ping command unavailable', [
'host' => $hostname,
'error' => $e->getMessage(),
]);
return null;
}
$data = $ping->toArray();
unset($data['raw_output'], $data['lines']);
+49 -2
View File
@@ -11,6 +11,9 @@ use Illuminate\Bus\Batchable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Queue\Middleware\SkipIfBatchCancelled;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Throwable;
class CheckForInternetConnectionJob implements ShouldQueue
{
@@ -46,11 +49,20 @@ class CheckForInternetConnectionJob implements ShouldQueue
$ping = PingHostname::run();
if ($ping->isSuccess()) {
if ($ping?->isSuccess()) {
return;
}
$message = sprintf('Failed to connected to hostname "%s". Error received "%s".', $ping->getHost(), $ping->error()?->value);
Log::debug('Pinged failed, falling back to HTTP connectivity check');
// Ping either failed or was unavailable — attempt an HTTP fallback.
if ($this->httpFallbackSucceeds()) {
return;
}
$message = $ping === null
? 'Ping command is unavailable and HTTP fallback also failed.'
: sprintf('Failed to connected to hostname "%s". Error received "%s". HTTP fallback also failed.', $ping->getHost(), $ping->error()?->value);
$this->result->update([
'data->type' => 'log',
@@ -63,4 +75,39 @@ class CheckForInternetConnectionJob implements ShouldQueue
$this->batch()->cancel();
}
/**
* Attempt to verify connectivity via an HTTP GET request as a fallback
* when ping is unavailable or unsuccessful.
*/
protected function httpFallbackSucceeds(): bool
{
$url = config('speedtest.preflight.external_ip_url');
try {
$response = Http::retry(3, 100)
->timeout(5)
->get(url: $url);
if ($response->ok()) {
Log::debug('HTTP fallback connectivity check succeeded', ['url' => $url]);
return true;
}
Log::debug('HTTP fallback connectivity check received non-OK response', [
'url' => $url,
'status' => $response->status(),
]);
return false;
} catch (Throwable $e) {
Log::debug('HTTP fallback connectivity check failed', [
'url' => $url,
'error' => $e->getMessage(),
]);
return false;
}
}
}
@@ -0,0 +1,152 @@
<?php
use App\Actions\PingHostname;
use App\Enums\ResultStatus;
use App\Events\SpeedtestFailed;
use App\Jobs\CheckForInternetConnectionJob;
use App\Models\Result;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Http;
use Spatie\Ping\PingResult;
beforeEach(function () {
Event::fake();
});
describe('CheckForInternetConnectionJob', function () {
test('batch continues when ping succeeds', function () {
$result = Result::factory()->create(['status' => ResultStatus::Started]);
$successfulPing = PingResult::fromArray(['success' => true, 'host' => 'icanhazip.com']);
app()->bind(PingHostname::class, fn () => new class($successfulPing)
{
public function __construct(private PingResult $ping) {}
public function handle(?string $hostname = null, int $count = 1): ?PingResult
{
return $this->ping;
}
});
[$job, $batch] = (new CheckForInternetConnectionJob($result))->withFakeBatch();
$job->handle();
$this->assertFalse($batch->cancelled());
$result->refresh();
expect($result->status)->toBe(ResultStatus::Checking);
Event::assertNotDispatched(SpeedtestFailed::class);
});
test('batch continues when ping fails but HTTP fallback succeeds', function () {
$result = Result::factory()->create(['status' => ResultStatus::Started]);
$failedPing = PingResult::fromArray([
'success' => false,
'error' => 'hostUnreachable',
'host' => 'icanhazip.com',
]);
app()->bind(PingHostname::class, fn () => new class($failedPing)
{
public function __construct(private PingResult $ping) {}
public function handle(?string $hostname = null, int $count = 1): ?PingResult
{
return $this->ping;
}
});
Http::fake([
'*' => Http::response('1.2.3.4', 200),
]);
[$job, $batch] = (new CheckForInternetConnectionJob($result))->withFakeBatch();
$job->handle();
$this->assertFalse($batch->cancelled());
$result->refresh();
expect($result->status)->toBe(ResultStatus::Checking);
Event::assertNotDispatched(SpeedtestFailed::class);
});
test('batch continues when ping is unavailable but HTTP fallback succeeds', function () {
$result = Result::factory()->create(['status' => ResultStatus::Started]);
app()->bind(PingHostname::class, fn () => new class
{
public function handle(?string $hostname = null, int $count = 1): ?PingResult
{
return null;
}
});
Http::fake([
'*' => Http::response('1.2.3.4', 200),
]);
[$job, $batch] = (new CheckForInternetConnectionJob($result))->withFakeBatch();
$job->handle();
$this->assertFalse($batch->cancelled());
$result->refresh();
expect($result->status)->toBe(ResultStatus::Checking);
Event::assertNotDispatched(SpeedtestFailed::class);
});
test('batch is cancelled when ping fails and HTTP fallback also fails', function () {
$result = Result::factory()->create(['status' => ResultStatus::Started]);
$failedPing = PingResult::fromArray([
'success' => false,
'error' => 'hostUnreachable',
'host' => 'icanhazip.com',
]);
app()->bind(PingHostname::class, fn () => new class($failedPing)
{
public function __construct(private PingResult $ping) {}
public function handle(?string $hostname = null, int $count = 1): ?PingResult
{
return $this->ping;
}
});
Http::fake([
'*' => Http::response('Service Unavailable', 503),
]);
[$job, $batch] = (new CheckForInternetConnectionJob($result))->withFakeBatch();
$job->handle();
$this->assertTrue($batch->cancelled());
$result->refresh();
expect($result->status)->toBe(ResultStatus::Failed);
expect($result->data['level'])->toBe('error');
expect($result->data['message'])->toContain('HTTP fallback also failed');
Event::assertDispatched(SpeedtestFailed::class);
});
test('batch is cancelled when ping is unavailable and HTTP fallback throws', function () {
$result = Result::factory()->create(['status' => ResultStatus::Started]);
app()->bind(PingHostname::class, fn () => new class
{
public function handle(?string $hostname = null, int $count = 1): ?PingResult
{
return null;
}
});
Http::fake([
'*' => Http::failedConnection(),
]);
[$job, $batch] = (new CheckForInternetConnectionJob($result))->withFakeBatch();
$job->handle();
$this->assertTrue($batch->cancelled());
$result->refresh();
expect($result->status)->toBe(ResultStatus::Failed);
expect($result->data['message'])->toBe('Ping command is unavailable and HTTP fallback also failed.');
Event::assertDispatched(SpeedtestFailed::class);
});
});