Skip to content

Commit e78d2c6

Browse files
committed
Handle timeouts more gracefully by throwing exception near the hard end
1 parent ae088d3 commit e78d2c6

6 files changed

Lines changed: 256 additions & 5 deletions

File tree

runtime/layers/fpm/bootstrap

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,8 @@ if (getenv('BREF_DOWNLOAD_VENDOR')) {
2525
require $appRoot . '/vendor/autoload.php';
2626
}
2727

28-
$lambdaRuntime = LambdaRuntime::fromEnvironmentVariable();
28+
// Get a LambdaRuntime and disable timeout exceptions.
29+
$lambdaRuntime = LambdaRuntime::fromEnvironmentVariable(-1);
2930

3031
$handlerFile = $appRoot . '/' . getenv('_HANDLER');
3132
if (! is_file($handlerFile)) {

src/Runtime/LambdaRuntime.php

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
use Bref\Context\Context;
66
use Bref\Context\ContextBuilder;
77
use Bref\Event\Handler;
8+
use Bref\Timeout\Timeout;
89
use Exception;
910
use Psr\Http\Server\RequestHandlerInterface;
1011

@@ -42,19 +43,33 @@ final class LambdaRuntime
4243
/** @var Invoker */
4344
private $invoker;
4445

45-
public static function fromEnvironmentVariable(): self
46+
/** @var int seconds */
47+
private $timeout;
48+
49+
public static function fromEnvironmentVariable(?int $timeout = null): self
4650
{
47-
return new self((string) getenv('AWS_LAMBDA_RUNTIME_API'));
51+
return new self((string) getenv('AWS_LAMBDA_RUNTIME_API'), $timeout ?? (int) getenv('BREF_TIMEOUT'));
4852
}
4953

50-
public function __construct(string $apiUrl)
54+
/**
55+
* @param int $timeout number of seconds before a TimeoutException is thrown.
56+
* Value -1 means "disabled". Value 0 means "auto", this will
57+
* set the timeout just a bit shorter than the Lambda timeout.
58+
*/
59+
public function __construct(string $apiUrl, int $timeout = 0)
5160
{
5261
if ($apiUrl === '') {
5362
die('At the moment lambdas can only be executed in an Lambda environment');
5463
}
5564

5665
$this->apiUrl = $apiUrl;
5766
$this->invoker = new Invoker;
67+
$this->timeout = $timeout;
68+
69+
if ($timeout >= 0 && ! Timeout::init()) {
70+
// If we fail to initialize
71+
$this->timeout = -1;
72+
}
5873
}
5974

6075
public function __destruct()
@@ -96,6 +111,13 @@ public function processNextEvent($handler): void
96111
[$event, $context] = $this->waitNextInvocation();
97112
\assert($context instanceof Context);
98113

114+
if ($this->timeout > 0) {
115+
Timeout::timeoutAfter($this->timeout);
116+
} elseif ($this->timeout === 0 && 0 < $context->getRemainingTimeInMillis()) {
117+
// Throw exception one second before Lambda pulls the plug.
118+
Timeout::timeoutAfter(max(1, (int) floor($context->getRemainingTimeInMillis() / 1000) - 1));
119+
}
120+
99121
$this->ping();
100122

101123
try {
@@ -104,6 +126,8 @@ public function processNextEvent($handler): void
104126
$this->sendResponse($context->getAwsRequestId(), $result);
105127
} catch (\Throwable $e) {
106128
$this->signalFailure($context->getAwsRequestId(), $e);
129+
} finally {
130+
Timeout::reset();
107131
}
108132
}
109133

src/Timeout/LambdaTimeout.php

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
<?php declare(strict_types=1);
2+
3+
namespace Bref\Timeout;
4+
5+
/**
6+
* The application took too long to produce a response. This exception is thrown
7+
* to give the application a chance to flush logs and shut it self down before
8+
* the power to AWS Lambda is disconnected.
9+
*/
10+
class LambdaTimeout extends \RuntimeException
11+
{
12+
}

src/Timeout/Timeout.php

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
<?php declare(strict_types=1);
2+
3+
namespace Bref\Timeout;
4+
5+
/**
6+
* Helper class to trigger an exception just before the Lamba times out. This
7+
* will give the application a chance to shut down.
8+
*/
9+
final class Timeout
10+
{
11+
/** @var bool */
12+
private static $initialized = false;
13+
14+
/**
15+
* Read environment variables and setup timeout exception.
16+
*/
17+
public static function enable(): void
18+
{
19+
if (isset($_SERVER['BREF_TIMEOUT'])) {
20+
$timeout = (int) $_SERVER['BREF_TIMEOUT'];
21+
if ($timeout === -1) {
22+
return;
23+
}
24+
25+
if ($timeout > 0) {
26+
self::timeoutAfter($timeout);
27+
28+
return;
29+
}
30+
31+
// else if 0, continue
32+
}
33+
34+
if (isset($_SERVER['LAMBDA_INVOCATION_CONTEXT'])) {
35+
$context = json_decode($_SERVER['LAMBDA_INVOCATION_CONTEXT'], true, 512, JSON_THROW_ON_ERROR);
36+
$deadlineMs = $context['deadlineMs'];
37+
$remainingTime = $deadlineMs - intval(microtime(true) * 1000);
38+
39+
self::timeoutAfter((int) floor($remainingTime / 1000));
40+
41+
return;
42+
}
43+
44+
throw new \LogicException('Could not find value for bref timeout. Are we running on Lambda?');
45+
}
46+
47+
/**
48+
* Setup custom handler for SIGTERM. One need to call Timeout::timoutAfter()
49+
* to make an exception to be thrown.
50+
*
51+
* @return bool true if successful.
52+
*/
53+
public static function init(): bool
54+
{
55+
if (self::$initialized) {
56+
return true;
57+
}
58+
59+
if (! function_exists('pcntl_async_signals')) {
60+
trigger_error('Could not enable timeout exceptions because pcntl extension is not enabled.');
61+
return false;
62+
}
63+
64+
pcntl_async_signals(true);
65+
pcntl_signal(SIGALRM, function (): void {
66+
throw new LambdaTimeout('Maximum AWS Lambda execution time reached');
67+
});
68+
69+
self::$initialized = true;
70+
71+
return true;
72+
}
73+
74+
/**
75+
* Set a timer to throw an exception.
76+
*/
77+
public static function timeoutAfter(int $seconds): void
78+
{
79+
self::init();
80+
pcntl_alarm($seconds);
81+
}
82+
83+
/**
84+
* Reset timeout.
85+
*/
86+
public static function reset(): void
87+
{
88+
if (self::$initialized) {
89+
pcntl_alarm(0);
90+
}
91+
}
92+
}

tests/Runtime/LambdaRuntimeTest.php

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ protected function setUp(): void
3535
{
3636
ob_start();
3737
Server::start();
38-
$this->runtime = new LambdaRuntime('localhost:8126');
38+
$this->runtime = new LambdaRuntime('localhost:8126', -1);
3939
}
4040

4141
protected function tearDown(): void
@@ -44,6 +44,29 @@ protected function tearDown(): void
4444
ob_end_clean();
4545
}
4646

47+
public function testFromEnvironmentVariable()
48+
{
49+
$getTimeout = function ($runtime) {
50+
$reflectionProp = (new \ReflectionObject($runtime))->getProperty('timeout');
51+
$reflectionProp->setAccessible(true);
52+
53+
return $reflectionProp->getValue($runtime);
54+
};
55+
56+
putenv('AWS_LAMBDA_RUNTIME_API=foo');
57+
putenv('BREF_TIMEOUT'); // unset
58+
$this->assertEquals(0, $getTimeout(LambdaRuntime::fromEnvironmentVariable()));
59+
$this->assertEquals(-1, $getTimeout(LambdaRuntime::fromEnvironmentVariable(-1)));
60+
$this->assertEquals(0, $getTimeout(LambdaRuntime::fromEnvironmentVariable(0)));
61+
$this->assertEquals(10, $getTimeout(LambdaRuntime::fromEnvironmentVariable(10)));
62+
63+
putenv('BREF_TIMEOUT=5');
64+
$this->assertEquals(5, $getTimeout(LambdaRuntime::fromEnvironmentVariable()));
65+
$this->assertEquals(-1, $getTimeout(LambdaRuntime::fromEnvironmentVariable(-1)));
66+
$this->assertEquals(0, $getTimeout(LambdaRuntime::fromEnvironmentVariable(0)));
67+
$this->assertEquals(10, $getTimeout(LambdaRuntime::fromEnvironmentVariable(10)));
68+
}
69+
4770
public function test basic behavior()
4871
{
4972
$this->givenAnEvent(['Hello' => 'world!']);

tests/Timeout/TimeoutTest.php

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
<?php declare(strict_types=1);
2+
3+
namespace Timeout;
4+
5+
use Bref\Timeout\LambdaTimeout;
6+
use Bref\Timeout\Timeout;
7+
use PHPUnit\Framework\TestCase;
8+
9+
class TimeoutTest extends TestCase
10+
{
11+
public static function setUpBeforeClass(): void
12+
{
13+
if (! function_exists('pcntl_async_signals')) {
14+
self::markTestSkipped('PCNTL extension is not enabled.');
15+
}
16+
}
17+
18+
protected function setUp(): void
19+
{
20+
parent::setUp();
21+
unset($_SERVER['LAMBDA_INVOCATION_CONTEXT']);
22+
unset($_SERVER['BREF_TIMEOUT']);
23+
}
24+
25+
protected function tearDown(): void
26+
{
27+
Timeout::reset();
28+
parent::tearDown();
29+
}
30+
31+
public function testEnableWithoutContext()
32+
{
33+
$this->expectException(\LogicException::class);
34+
Timeout::enable();
35+
}
36+
37+
public function testEnableWithBrefTimeoutInactive()
38+
{
39+
$_SERVER['BREF_TIMEOUT'] = -1;
40+
$_SERVER['LAMBDA_INVOCATION_CONTEXT'] = json_encode(['deadlineMs' => (time() + 30) * 1000]);
41+
42+
Timeout::enable();
43+
$timeout = pcntl_alarm(0);
44+
$this->assertSame(0, $timeout, 'Timeout should not be active when BREF_TIMEOUT=-1');
45+
}
46+
47+
public function testEnableWithBrefTimeout()
48+
{
49+
$_SERVER['BREF_TIMEOUT'] = 10;
50+
51+
Timeout::enable();
52+
$timeout = pcntl_alarm(0);
53+
$this->assertSame(10, $timeout, 'BREF_TIMEOUT=10 should have effect');
54+
}
55+
56+
public function testEnableWithBrefTimeoutAndContext()
57+
{
58+
$_SERVER['BREF_TIMEOUT'] = 10;
59+
$_SERVER['LAMBDA_INVOCATION_CONTEXT'] = json_encode(['deadlineMs' => (time() + 30) * 1000]);
60+
61+
Timeout::enable();
62+
$timeout = pcntl_alarm(0);
63+
$this->assertSame(10, $timeout, 'BREF_TIMEOUT=10 should have effect over context');
64+
}
65+
66+
public function testEnableWithBrefTimeoutZeroAndContext()
67+
{
68+
$_SERVER['BREF_TIMEOUT'] = 0;
69+
$_SERVER['LAMBDA_INVOCATION_CONTEXT'] = json_encode(['deadlineMs' => (time() + 30) * 1000]);
70+
71+
Timeout::enable();
72+
$timeout = pcntl_alarm(0);
73+
$this->assertEqualsWithDelta(30, $timeout, 1, 'BREF_TIMEOUT=0 should fallback to context');
74+
}
75+
76+
public function testEnableWithContext()
77+
{
78+
$_SERVER['LAMBDA_INVOCATION_CONTEXT'] = json_encode(['deadlineMs' => (time() + 30) * 1000]);
79+
80+
Timeout::enable();
81+
$timeout = pcntl_alarm(0);
82+
$this->assertEqualsWithDelta(30, $timeout, 1);
83+
}
84+
85+
public function testTimeoutAfter()
86+
{
87+
$start = microtime(true);
88+
Timeout::timeoutAfter(2);
89+
try {
90+
sleep(4);
91+
$this->fail('We expect a LambdaTimeout before we reach this line');
92+
} catch (LambdaTimeout $e) {
93+
$time = 1000 * (microtime(true) - $start);
94+
$this->assertEqualsWithDelta(2000, $time, 200, 'We must wait about 2 seconds');
95+
} catch (\Throwable $e) {
96+
$this->fail('It must throw a LambdaTimeout.');
97+
}
98+
}
99+
}

0 commit comments

Comments
 (0)