Skip to content

Commit e24206b

Browse files
committed
[Bref] Added support for handle timeouts
1 parent b289dec commit e24206b

File tree

7 files changed

+245
-3
lines changed

7 files changed

+245
-3
lines changed

src/bref/CHANGELOG.md

+2
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ you will get an exception. Run `composer require bref/bref` to solve the issue.
2020
- We do not use internal classes in Bref any more (https://github.com/php-runtime/runtime/pull/88)
2121
- Some handlers do not require the `bref/bref` package (https://github.com/php-runtime/runtime/pull/89)
2222
- We include a runtime specific Bref layer (https://github.com/php-runtime/bref-layer)
23+
- Support for handle timeouts and lets your application shut down. This will shorten
24+
the actual execution time with 2 seconds.
2325

2426
### Fixed
2527

src/bref/README.md

+41
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ We support all kinds of applications. See the following sections for details.
1414
1. [Simplify serverless.yml](#simplify-serverlessyml)
1515
1. [Typed handlers](#typed-handlers)
1616
1. [Symfony Messenger integration](#symfony-messenger-integration)
17+
1. [Handle timeouts](#handle-timeouts)
1718

1819
If you are new to the Symfony Runtime component, read more in the
1920
[main readme](https://github.com/php-runtime/runtime).
@@ -399,3 +400,43 @@ resources:
399400
QueueName: ${self:service}-workqueue
400401
VisibilityTimeout: 600
401402
```
403+
404+
## Handle timeouts
405+
406+
When a Lambda function times out, it is like the power to the computer is suddenly
407+
just turned off. This does not give the application a chance to shut down properly.
408+
This leaves you without any logs and the problem could be hard to fix.
409+
410+
To allow your application to shut down properly and write logs, we will throw an
411+
exception just before the Lambda times out.
412+
413+
Whenever a timeout happens, a full stack trace will be logged, including the line
414+
that was executing. In most cases, it is an external call to a database, cache or
415+
API that is stuck waiting.
416+
417+
### Catching the exception
418+
419+
You can catch the timeout exception to perform some cleanup, logs or even display
420+
a proper error page. If you are using a framework, this is likely done for you.
421+
Here is an example of a simple handler catching the timeout exception
422+
423+
```php
424+
use Bref\Context\Context;
425+
use Bref\Event\Handler;
426+
use Runtime\Bref\Timeout\LambdaTimeoutException;
427+
428+
class MySlowHandler implements Handler
429+
{
430+
public function handle($event, Context $context)
431+
{
432+
try {
433+
// your code here
434+
// ...
435+
} catch (LambdaTimeoutException $e) {
436+
return 'Oops, sorry. We spent too much time on this.';
437+
} catch (\Throwable $e) {
438+
return 'Some other unexpected error happened.';
439+
}
440+
}
441+
}
442+
```

src/bref/src/Lambda/LambdaClient.php

+9
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
use Bref\Context\Context;
66
use Bref\Event\Handler;
77
use Exception;
8+
use Runtime\Bref\Timeout\Timeout;
89

910
/**
1011
* A port of LambdaRuntime from bref/bref package. That class is internal so
@@ -100,6 +101,12 @@ public function processNextEvent(Handler $handler): bool
100101
[$event, $context] = $this->waitNextInvocation();
101102
\assert($context instanceof Context);
102103

104+
$remainingTimeInMillis = $context->getRemainingTimeInMillis();
105+
if (0 < $remainingTimeInMillis) {
106+
// Throw exception before Lambda pulls the plug.
107+
Timeout::enable($remainingTimeInMillis);
108+
}
109+
103110
$this->ping();
104111

105112
try {
@@ -110,6 +117,8 @@ public function processNextEvent(Handler $handler): bool
110117
$this->signalFailure($context->getAwsRequestId(), $e);
111118

112119
return false;
120+
} finally {
121+
Timeout::reset();
113122
}
114123

115124
return true;
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
<?php
2+
3+
namespace Runtime\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 itself down before
8+
* the power to AWS Lambda is disconnected.
9+
*
10+
* @author Tobias Nyholm <[email protected]>
11+
*/
12+
class LambdaTimeoutException extends \RuntimeException
13+
{
14+
}

src/bref/src/Timeout/Timeout.php

+97
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
<?php
2+
3+
namespace Runtime\Bref\Timeout;
4+
5+
/**
6+
* Helper class to trigger an exception just before the Lambda times out. This
7+
* will give the application a chance to shut down.
8+
*
9+
* @author Tobias Nyholm <[email protected]>
10+
*/
11+
final class Timeout
12+
{
13+
/** @var bool */
14+
private static $initialized = false;
15+
16+
/** @var string|null */
17+
private static $stackTrace = null;
18+
19+
/**
20+
* @internal
21+
*/
22+
public static function enable(int $remainingTimeInMillis): void
23+
{
24+
self::init();
25+
26+
if (!self::$initialized) {
27+
return;
28+
}
29+
30+
$remainingTimeInSeconds = (int) floor($remainingTimeInMillis / 1000);
31+
32+
// The script will timeout 2 seconds before the remaining time
33+
// to allow some time for Bref/our app to recover and cleanup
34+
$margin = 2;
35+
36+
$timeoutDelayInSeconds = max(1, $remainingTimeInSeconds - $margin);
37+
38+
// Trigger SIGALRM in X seconds
39+
pcntl_alarm($timeoutDelayInSeconds);
40+
}
41+
42+
/**
43+
* Setup custom handler for SIGALRM.
44+
*/
45+
private static function init(): void
46+
{
47+
self::$stackTrace = null;
48+
49+
if (self::$initialized) {
50+
return;
51+
}
52+
53+
if (!function_exists('pcntl_async_signals')) {
54+
trigger_error('Could not enable timeout exceptions because pcntl extension is not enabled.');
55+
56+
return;
57+
}
58+
59+
pcntl_async_signals(true);
60+
// Setup a handler for SIGALRM that throws an exception
61+
// This will interrupt any running PHP code, including `sleep()` or code stuck waiting for I/O.
62+
pcntl_signal(SIGALRM, function (): void {
63+
if (null !== Timeout::$stackTrace) {
64+
// we have already thrown an exception, do a harder exit.
65+
error_log('Lambda timed out');
66+
error_log((new LambdaTimeoutException())->getTraceAsString());
67+
error_log('Original stack trace');
68+
error_log(Timeout::$stackTrace);
69+
70+
exit(1);
71+
}
72+
73+
$exception = new LambdaTimeoutException('Maximum AWS Lambda execution time reached');
74+
Timeout::$stackTrace = $exception->getTraceAsString();
75+
76+
// Trigger another alarm after 1 second to do a hard exit.
77+
pcntl_alarm(1);
78+
79+
throw $exception;
80+
});
81+
82+
self::$initialized = true;
83+
}
84+
85+
/**
86+
* Cancel all current timeouts.
87+
*
88+
* @internal
89+
*/
90+
public static function reset(): void
91+
{
92+
if (self::$initialized) {
93+
pcntl_alarm(0);
94+
self::$stackTrace = null;
95+
}
96+
}
97+
}

src/bref/tests/Lambda/LambdaClientTest.php

+29-3
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@
88
use GuzzleHttp\Server\Server;
99
use PHPUnit\Framework\TestCase;
1010
use Runtime\Bref\Lambda\LambdaClient;
11+
use Runtime\Bref\Timeout\LambdaTimeoutException;
12+
use Runtime\Bref\Timeout\Timeout;
1113

1214
/**
1315
* Tests the communication between `LambdaClient` and the Lambda Runtime HTTP API.
@@ -189,9 +191,9 @@ public function handle($event, Context $context)
189191

190192
// Check the lambda result contains the error message
191193
$error = json_decode((string) $eventFailureLog->getBody(), true);
192-
$this->assertSame('Error while calling the Lambda runtime API: The requested URL returned error: 400 Bad Request', $error['errorMessage']);
194+
$this->assertStringContainsString('Error while calling the Lambda runtime API: The requested URL returned error: 400', $error['errorMessage']);
193195

194-
$this->assertErrorInLogs('Exception', 'Error while calling the Lambda runtime API: The requested URL returned error: 400 Bad Request');
196+
$this->assertErrorInLogs('Exception', 'Error while calling the Lambda runtime API: The requested URL returned error: 400');
195197
}
196198

197199
public function test function results that cannot be encoded are reported as invocation errors()
@@ -235,6 +237,28 @@ public function handle($event, Context $context)
235237
$this->assertInvocationResult(['foo' => 'bar']);
236238
}
237239

240+
public function testLambdaTimeoutsCanBeAnticipated()
241+
{
242+
$this->givenAnEvent([]);
243+
244+
$start = microtime(true);
245+
$this->lambda->processNextEvent(new class() implements Handler {
246+
public function handle($event, Context $context)
247+
{
248+
// This 10s sleep should be interrupted
249+
sleep(10);
250+
}
251+
});
252+
253+
$elapsedTime = microtime(true) - $start;
254+
// The Lambda timeout was 2 seconds, we expect the Bref timeout to trigger 1 second before that: 1 second
255+
$this->assertEqualsWithDelta(1, $elapsedTime, 0.2);
256+
Timeout::reset();
257+
258+
$this->assertInvocationErrorResult(LambdaTimeoutException::class, 'Maximum AWS Lambda execution time reached');
259+
$this->assertErrorInLogs(LambdaTimeoutException::class, 'Maximum AWS Lambda execution time reached');
260+
}
261+
238262
/**
239263
* @param mixed $event
240264
*/
@@ -246,6 +270,8 @@ private function givenAnEvent($event): void
246270
[
247271
'lambda-runtime-aws-request-id' => '1',
248272
'lambda-runtime-invoked-function-arn' => 'test-function-name',
273+
// now + 2 seconds
274+
'lambda-runtime-deadline-ms' => intval((microtime(true) + 2) * 1000),
249275
],
250276
json_encode($event)
251277
),
@@ -312,7 +338,7 @@ private function assertErrorInLogs(string $errorClass, string $errorMessage): vo
312338
'stack',
313339
], array_keys($invocationResult));
314340
$this->assertEquals($errorClass, $invocationResult['errorType']);
315-
$this->assertEquals($errorMessage, $invocationResult['errorMessage']);
341+
$this->assertStringContainsString($errorMessage, $invocationResult['errorMessage']);
316342
$this->assertIsArray($invocationResult['stack']);
317343
}
318344

+53
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
<?php
2+
3+
namespace Runtime\Bref\Tests;
4+
5+
use PHPUnit\Framework\TestCase;
6+
use Runtime\Bref\Timeout\LambdaTimeoutException;
7+
use Runtime\Bref\Timeout\Timeout;
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+
}
23+
24+
protected function tearDown(): void
25+
{
26+
Timeout::reset();
27+
parent::tearDown();
28+
}
29+
30+
public function testEnable()
31+
{
32+
Timeout::enable(3000);
33+
$timeout = pcntl_alarm(0);
34+
// 1 second (2 seconds shorter than the 3s remaining time)
35+
$this->assertSame(1, $timeout);
36+
}
37+
38+
public function testTimeoutsAreInterruptedInTime()
39+
{
40+
$start = microtime(true);
41+
Timeout::enable(3000);
42+
try {
43+
sleep(4);
44+
$this->fail('We expect a LambdaTimeout before we reach this line');
45+
} catch (LambdaTimeoutException $e) {
46+
$time = 1000 * (microtime(true) - $start);
47+
$this->assertEqualsWithDelta(1000, $time, 200, 'We must wait about 1 second');
48+
Timeout::reset();
49+
} catch (\Throwable $e) {
50+
$this->fail('It must throw a LambdaTimeout.');
51+
}
52+
}
53+
}

0 commit comments

Comments
 (0)