Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions config/location.php
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,19 @@
'connect_timeout' => 3,
],

/*
|--------------------------------------------------------------------------
| Total Timeout
|--------------------------------------------------------------------------
|
| The seconds a lookup may take across the driver above and all of its
| fallbacks. Set to null to instead give each driver its own timeout,
| which makes a failing lookup take them all added up.
|
*/

'total_timeout' => null,

/*
|--------------------------------------------------------------------------
| Localhost Testing
Expand Down
27 changes: 27 additions & 0 deletions readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,33 @@ information from the visitor.
If an exception occurs trying to grab a driver (such as a 400/500 error if the
providers API changes), it will automatically use the next driver in line.

### Timeouts

The `http` config option times out a single driver's request. Each driver gets
its own, so with the default configuration an unreachable provider costs you
that timeout five times over before the lookup gives up.

Set `total_timeout` to give the whole lookup one shared budget instead:

```php
'total_timeout' => 5,
```

### Observing failures

Drivers fail quietly so the next fallback can take over. To see why one failed,
listen for the `LookupFailed` event:

```php
use Stevebauman\Location\Events\LookupFailed;

Event::listen(function (LookupFailed $event) {
Log::warning("Location lookup failed via [$event->driver].", [
'ip' => $event->ip, 'exception' => $event->exception,
]);
});
```

### Creating your own drivers

To create your own driver, simply create a class in your application, and extend the abstract Driver:
Expand Down
39 changes: 39 additions & 0 deletions src/Deadline.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
<?php

namespace Stevebauman\Location;

use Closure;

class Deadline
{
/**
* The timestamp the current deadline expires at.
*/
protected static ?float $expiresAt = null;

/**
* Run the callback with a deadline of the given seconds, if any.
*/
public static function for(?float $seconds, Closure $callback): mixed
{
$previous = static::$expiresAt;

static::$expiresAt = is_null($seconds) ? null : microtime(true) + $seconds;

try {
return $callback();
} finally {
static::$expiresAt = $previous;
}
}

/**
* Get the seconds left, or null when there is no deadline.
*/
public static function remaining(): ?float
{
return is_null(static::$expiresAt)
? null
: max(static::$expiresAt - microtime(true), 0.0);
}
}
51 changes: 41 additions & 10 deletions src/Drivers/HttpDriver.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,10 @@
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Fluent;
use Stevebauman\Location\Deadline;
use Stevebauman\Location\Events\LookupFailed;
use Stevebauman\Location\Request;
use Throwable;

abstract class HttpDriver extends Driver
{
Expand All @@ -33,15 +36,24 @@ public static function resolveHttpBy(Closure $callback): void
*/
public function process(Request $request): Fluent|false
{
return rescue(function () use ($request) {
if (Deadline::remaining() === 0.0) {
return false;
}

try {
$response = $this->http()->acceptJson()->get(
$this->url($request->getIp())
);

throw_if($response->failed());
return new Fluent($response->throw()->json());
} catch (Throwable $e) {
// Failures are expected, they're what calls the next fallback
// driver. We won't report them to keep the log quiet, but
// we will announce them so they can still be observed.
event(new LookupFailed(static::class, $request->getIp(), $e));

return new Fluent($response->json());
}, false, false);
return false;
}
}

/**
Expand All @@ -51,11 +63,30 @@ protected function http(): PendingRequest
{
$callback = static::$httpResolver ?: fn ($http) => $http;

return value($callback, Http::withOptions(
config('location.http', [
'timeout' => 3,
'connect_timeout' => 3,
])
));
return value($callback, Http::withOptions($this->options()));
}

/**
* Get the options to use for the HTTP request.
*/
protected function options(): array
{
$options = config('location.http', [
'timeout' => 3,
'connect_timeout' => 3,
]);

if (is_null($remaining = Deadline::remaining())) {
return $options;
}

// No request may outlive what's left of the lookup's budget. A
// missing or zero timeout is unlimited as far as Guzzle is
// concerned, so those get the whole remaining budget.
foreach (['timeout', 'connect_timeout'] as $option) {
$options[$option] = min(($options[$option] ?? 0) ?: INF, $remaining);
}

return $options;
}
}
14 changes: 14 additions & 0 deletions src/Events/LookupFailed.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<?php

namespace Stevebauman\Location\Events;

use Throwable;

class LookupFailed
{
public function __construct(
public string $driver,
public string $ip,
public Throwable $exception,
) {}
}
5 changes: 4 additions & 1 deletion src/LocationManager.php
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,10 @@ public function setDefaultDriver(): static
*/
public function get(?string $ip = null): Position|false
{
return $this->driver->get($this->request()->setIp($ip));
return Deadline::for(
config('location.total_timeout'),
fn () => $this->driver->get($this->request()->setIp($ip))
);
}

/**
Expand Down
47 changes: 47 additions & 0 deletions tests/Drivers/HttpDriverTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
<?php

namespace Stevebauman\Location\Tests\Drivers;

use Illuminate\Http\Client\ConnectionException;
use Illuminate\Http\Client\RequestException;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Http;
use Stevebauman\Location\Drivers\IpApi;
use Stevebauman\Location\Events\LookupFailed;
use Stevebauman\Location\Facades\Location;
use Stevebauman\Location\Position;

beforeEach(function () {
Event::fake([LookupFailed::class]);

config(['location.driver' => IpApi::class, 'location.fallbacks' => []]);
});

it('announces a failure to connect', function () {
Http::fake(fn () => throw new ConnectionException('cURL error 28: Operation timed out'));

expect(Location::get('8.8.8.8'))->toBeFalse();

Event::assertDispatched(LookupFailed::class, fn (LookupFailed $e) => $e->driver === IpApi::class
&& $e->ip === '8.8.8.8'
&& $e->exception instanceof ConnectionException
);
});

it('announces an unsuccessful response', function () {
Http::fake(fn () => Http::response('Service Unavailable', 503));

expect(Location::get('8.8.8.8'))->toBeFalse();

Event::assertDispatched(LookupFailed::class, fn (LookupFailed $e) => $e->exception instanceof RequestException
&& $e->exception->response->status() === 503
);
});

it('announces nothing on success', function () {
Http::fake(fn () => Http::response(['countryCode' => 'US']));

expect(Location::get('8.8.8.8'))->toBeInstanceOf(Position::class);

Event::assertNotDispatched(LookupFailed::class);
});
81 changes: 81 additions & 0 deletions tests/TotalTimeoutTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
<?php

namespace Stevebauman\Location\Tests;

use ArrayObject;
use Illuminate\Support\Facades\Http;
use Stevebauman\Location\Drivers\GeoPlugin;
use Stevebauman\Location\Drivers\HttpDriver;
use Stevebauman\Location\Drivers\IpApi;
use Stevebauman\Location\Drivers\IpInfo;
use Stevebauman\Location\Facades\Location;

$captured = new ArrayObject;

beforeEach(function () use ($captured) {
$captured->exchangeArray([]);

HttpDriver::resolveHttpBy(function ($http) use ($captured) {
$captured[] = $http->getOptions();

return $http;
});

config([
'location.driver' => IpApi::class,
'location.fallbacks' => [IpInfo::class, GeoPlugin::class],
'location.http' => ['timeout' => 3, 'connect_timeout' => 3],
'location.total_timeout' => null,
]);

Http::fake(function () {
usleep(200_000);

return Http::response('', 500);
});
});

afterEach(fn () => HttpDriver::resolveHttpBy(fn ($http) => $http));

it('leaves the configured timeouts alone without a total timeout', function () use ($captured) {
expect(Location::get('8.8.8.8'))->toBeFalse();

foreach ($captured as $options) {
expect($options['timeout'])->toBe(3)->and($options['connect_timeout'])->toBe(3);
}

expect($captured)->toHaveCount(3);
});

it('caps each driver to the time left in the total budget', function () use ($captured) {
config(['location.total_timeout' => 1]);

expect(Location::get('8.8.8.8'))->toBeFalse();
expect($captured)->toHaveCount(3);

// Each driver burns roughly 200ms, so the budget keeps shrinking.
expect($captured[0]['timeout'])->toBeLessThanOrEqual(1.0)
->and($captured[1]['timeout'])->toBeLessThan($captured[0]['timeout'])
->and($captured[2]['timeout'])->toBeLessThan($captured[1]['timeout']);
});

it('caps timeouts that are unset or disabled', function () use ($captured) {
config(['location.http' => ['timeout' => 0], 'location.total_timeout' => 1]);

expect(Location::get('8.8.8.8'))->toBeFalse();

expect($captured[0]['timeout'])->toBeGreaterThan(0)->toBeLessThanOrEqual(1.0)
->and($captured[0]['connect_timeout'])->toBeGreaterThan(0)->toBeLessThanOrEqual(1.0);
});

it('stops calling drivers once the total budget is spent', function () use ($captured) {
config(['location.total_timeout' => 0.3]);

expect(Location::get('8.8.8.8'))->toBeFalse();
expect($captured)->toHaveCount(2);

// The budget belongs to the lookup, not to the process.
Location::get('8.8.4.4');

expect($captured)->toHaveCount(4);
});