-
-
Notifications
You must be signed in to change notification settings - Fork 367
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Handle timeouts more gracefully by throwing exception near the hard end
- Loading branch information
Showing
6 changed files
with
256 additions
and
5 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,12 @@ | ||
<?php declare(strict_types=1); | ||
|
||
namespace Bref\Timeout; | ||
|
||
/** | ||
* The application took too long to produce a response. This exception is thrown | ||
* to give the application a chance to flush logs and shut it self down before | ||
* the power to AWS Lambda is disconnected. | ||
*/ | ||
class LambdaTimeout extends \RuntimeException | ||
{ | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,92 @@ | ||
<?php declare(strict_types=1); | ||
|
||
namespace Bref\Timeout; | ||
|
||
/** | ||
* Helper class to trigger an exception just before the Lamba times out. This | ||
* will give the application a chance to shut down. | ||
*/ | ||
final class Timeout | ||
{ | ||
/** @var bool */ | ||
private static $initialized = false; | ||
|
||
/** | ||
* Read environment variables and setup timeout exception. | ||
*/ | ||
public static function enable(): void | ||
{ | ||
if (isset($_SERVER['BREF_TIMEOUT'])) { | ||
$timeout = (int) $_SERVER['BREF_TIMEOUT']; | ||
if ($timeout === -1) { | ||
return; | ||
} | ||
|
||
if ($timeout > 0) { | ||
self::timoutAfter($timeout); | ||
|
||
return; | ||
} | ||
|
||
// else if 0, continue | ||
} | ||
|
||
if (isset($_SERVER['LAMBDA_INVOCATION_CONTEXT'])) { | ||
$context = json_decode($_SERVER['LAMBDA_INVOCATION_CONTEXT'], true, 512, JSON_THROW_ON_ERROR); | ||
$deadlineMs = $context['deadlineMs']; | ||
$remainingTime = $deadlineMs - intval(microtime(true) * 1000); | ||
|
||
self::timoutAfter((int) floor($remainingTime / 1000)); | ||
|
||
return; | ||
} | ||
|
||
throw new \LogicException('Could not find value for bref timeout. Are we running on Lambda?'); | ||
} | ||
|
||
/** | ||
* Setup custom handler for SIGTERM. One need to call Timeout::timoutAfter() | ||
* to make an exception to be thrown. | ||
* | ||
* @return bool true if successful. | ||
*/ | ||
public static function init(): bool | ||
{ | ||
if (self::$initialized) { | ||
return true; | ||
} | ||
|
||
if (! function_exists('pcntl_async_signals')) { | ||
trigger_error('Could not enable timeout exceptions because pcntl extension is not enabled.'); | ||
return false; | ||
} | ||
|
||
pcntl_async_signals(true); | ||
pcntl_signal(SIGALRM, function (): void { | ||
throw new LambdaTimeout('Maximum AWS Lambda execution time reached'); | ||
}); | ||
|
||
self::$initialized = true; | ||
|
||
return true; | ||
} | ||
|
||
/** | ||
* Set a timer to throw an exception. | ||
*/ | ||
public static function timoutAfter(int $seconds): void | ||
{ | ||
self::init(); | ||
pcntl_alarm($seconds); | ||
} | ||
|
||
/** | ||
* Reset timeout. | ||
*/ | ||
public static function reset(): void | ||
{ | ||
if (self::$initialized) { | ||
pcntl_alarm(0); | ||
} | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,99 @@ | ||
<?php declare(strict_types=1); | ||
|
||
namespace Timeout; | ||
|
||
use Bref\Timeout\LambdaTimeout; | ||
use Bref\Timeout\Timeout; | ||
use PHPUnit\Framework\TestCase; | ||
|
||
class TimeoutTest extends TestCase | ||
{ | ||
public static function setUpBeforeClass(): void | ||
{ | ||
if (! function_exists('pcntl_async_signals')) { | ||
self::markTestSkipped('PCNTL extension is not enabled.'); | ||
} | ||
} | ||
|
||
protected function setUp(): void | ||
{ | ||
parent::setUp(); | ||
unset($_SERVER['LAMBDA_INVOCATION_CONTEXT']); | ||
unset($_SERVER['BREF_TIMEOUT']); | ||
} | ||
|
||
protected function tearDown(): void | ||
{ | ||
Timeout::reset(); | ||
parent::tearDown(); | ||
} | ||
|
||
public function testEnableWithoutContext() | ||
{ | ||
$this->expectException(\LogicException::class); | ||
Timeout::enable(); | ||
} | ||
|
||
public function testEnableWithBrefTimeoutInactive() | ||
{ | ||
$_SERVER['BREF_TIMEOUT'] = -1; | ||
$_SERVER['LAMBDA_INVOCATION_CONTEXT'] = json_encode(['deadlineMs' => (time() + 30) * 1000]); | ||
|
||
Timeout::enable(); | ||
$timeout = pcntl_alarm(0); | ||
$this->assertSame(0, $timeout, 'Timeout should not be active when BREF_TIMEOUT=-1'); | ||
} | ||
|
||
public function testEnableWithBrefTimeout() | ||
{ | ||
$_SERVER['BREF_TIMEOUT'] = 10; | ||
|
||
Timeout::enable(); | ||
$timeout = pcntl_alarm(0); | ||
$this->assertSame(10, $timeout, 'BREF_TIMEOUT=10 should have effect'); | ||
} | ||
|
||
public function testEnableWithBrefTimeoutAndContext() | ||
{ | ||
$_SERVER['BREF_TIMEOUT'] = 10; | ||
$_SERVER['LAMBDA_INVOCATION_CONTEXT'] = json_encode(['deadlineMs' => (time() + 30) * 1000]); | ||
|
||
Timeout::enable(); | ||
$timeout = pcntl_alarm(0); | ||
$this->assertSame(10, $timeout, 'BREF_TIMEOUT=10 should have effect over context'); | ||
} | ||
|
||
public function testEnableWithBrefTimeoutZeroAndContext() | ||
{ | ||
$_SERVER['BREF_TIMEOUT'] = 0; | ||
$_SERVER['LAMBDA_INVOCATION_CONTEXT'] = json_encode(['deadlineMs' => (time() + 30) * 1000]); | ||
|
||
Timeout::enable(); | ||
$timeout = pcntl_alarm(0); | ||
$this->assertEqualsWithDelta(30, $timeout, 1, 'BREF_TIMEOUT=0 should fallback to context'); | ||
} | ||
|
||
public function testEnableWithContext() | ||
{ | ||
$_SERVER['LAMBDA_INVOCATION_CONTEXT'] = json_encode(['deadlineMs' => (time() + 30) * 1000]); | ||
|
||
Timeout::enable(); | ||
$timeout = pcntl_alarm(0); | ||
$this->assertEqualsWithDelta(30, $timeout, 1); | ||
} | ||
|
||
public function testTimeoutAfter() | ||
{ | ||
$start = microtime(true); | ||
Timeout::timoutAfter(2); | ||
try { | ||
sleep(4); | ||
$this->fail('We expect a LambdaTimeout before we reach this line'); | ||
} catch (LambdaTimeout $e) { | ||
$time = 1000 * (microtime(true) - $start); | ||
$this->assertEqualsWithDelta(2000, $time, 200, 'We must wait about 2 seconds'); | ||
} catch (\Throwable $e) { | ||
$this->fail('It must throw a LambdaTimeout.'); | ||
} | ||
} | ||
} |