Repository: brefphp/bref (language: php)

Issue:
Handle "Response too big" in Console functions
When invoking a (Symfony CLI) Lambda that outputs a lot of logging, I see a `ResponseTooBig` error:

```
Invoke Error	{"errorType":"Bref\\Runtime\\ResponseTooBig","errorMessage":"The Lambda response is too big and above the limit: 6 MB for HTTP responses and other synchronous invocations, 256 KB for asynchronous invocations (https:\/\/docs.aws.amazon.com\/lambda\/latest\/dg\/gettingstarted-limits.html#function-configuration-deployment-and-execution)","stack":["#0 \/var\/task\/vendor\/bref\/bref\/src\/Runtime\/LambdaRuntime.php(190): Bref\\Runtime\\LambdaRuntime->postJson('http:\/\/127.0.0....', Array)","#1 \/var\/task\/vendor\/bref\/bref\/src\/Runtime\/LambdaRuntime.php(96): Bref\\Runtime\\LambdaRuntime->sendResponse('abffc807-7372-4...', Array)","#2 \/var\/task\/vendor\/bref\/bref\/src\/ConsoleRuntime\/Main.php(36): Bref\\Runtime\\LambdaRuntime->processNextEvent(Object(Closure))","#3 \/opt\/bref\/bootstrap.php(17): Bref\\ConsoleRuntime\\Main::run()","#4 {main}"]}
```

The error is triggered by [Lambda’s 6 MB request/response payload limit](https://github.com/brefphp/bref/discussions/1062#discussioncomment-1524483).

I’d like to keep the verbosity of the logs to CloudWatch, but _not_ have them all printed to stdout/stderr, which causes the response to become too big. Is it possible to differentiate between CloudWatch and CLI output?


Discussion / hints:
Right now it's not possible indeed, could be an improvement to add 👍

Fix patch:
diff --git a/src/Bref.php b/src/Bref.php
index 06d93a52c..147f74729 100644
--- a/src/Bref.php
+++ b/src/Bref.php
@@ -104,5 +104,10 @@ public static function reset(): void
     {
         self::$containerProvider = null;
         self::$container = null;
+        self::$hooks = [
+            'beforeStartup' => [],
+            'beforeInvoke' => [],
+        ];
+        self::$eventDispatcher = new EventDispatcher;
     }
 }
diff --git a/src/ConsoleRuntime/CommandFailed.php b/src/ConsoleRuntime/CommandFailed.php
new file mode 100644
index 000000000..65f4bffb3
--- /dev/null
+++ b/src/ConsoleRuntime/CommandFailed.php
@@ -0,0 +1,9 @@
+<?php declare(strict_types=1);
+
+namespace Bref\ConsoleRuntime;
+
+use Exception;
+
+class CommandFailed extends Exception
+{
+}
diff --git a/src/ConsoleRuntime/Main.php b/src/ConsoleRuntime/Main.php
index 422817a02..b2d29fa00 100755
--- a/src/ConsoleRuntime/Main.php
+++ b/src/ConsoleRuntime/Main.php
@@ -6,7 +6,6 @@
 use Bref\Context\Context;
 use Bref\LazySecretsLoader;
 use Bref\Runtime\LambdaRuntime;
-use Exception;
 use Symfony\Component\Process\Process;
 
 /**
@@ -44,7 +43,7 @@ public static function run(): void
                 }
 
                 $timeout = max(1, $context->getRemainingTimeInMillis() / 1000 - 1);
-                $command = sprintf('/opt/bin/php %s %s 2>&1', $handlerFile, $cliOptions);
+                $command = sprintf('php %s %s 2>&1', $handlerFile, $cliOptions);
                 $process = Process::fromShellCommandline($command, null, null, null, $timeout);
 
                 $process->run(function ($type, $buffer): void {
@@ -53,13 +52,21 @@ public static function run(): void
 
                 $exitCode = $process->getExitCode();
 
+                $output = $process->getOutput();
+                // Trim the output to stay under the 6MB limit for AWS Lambda
+                // We only keep 5MB because at this point the difference won't be important
+                // and we'll serialize the output to JSON which will add some overhead
+                $output = substr($output, max(0, strlen($output) - 5 * 1024 * 1024));
+
                 if ($exitCode > 0) {
-                    throw new Exception('The command exited with a non-zero status code: ' . $exitCode);
+                    // This needs to be thrown so that AWS Lambda knows the invocation failed
+                    // (e.g. important for error rates in CloudWatch)
+                    throw new CommandFailed($output);
                 }
 
                 return [
                     'exitCode' => $exitCode, // will always be 0
-                    'output' => $process->getOutput(),
+                    'output' => $output,
                 ];
             });
         }


Test patch:
diff --git a/tests/ConsoleRuntime/MainTest.php b/tests/ConsoleRuntime/MainTest.php
index 069ba9ee1..37785ae50 100644
--- a/tests/ConsoleRuntime/MainTest.php
+++ b/tests/ConsoleRuntime/MainTest.php
@@ -3,12 +3,22 @@
 namespace Bref\Test\ConsoleRuntime;
 
 use Bref\Bref;
+use Bref\ConsoleRuntime\CommandFailed;
 use Bref\ConsoleRuntime\Main;
+use Bref\Test\RuntimeTestCase;
+use Bref\Test\Server;
 use Exception;
-use PHPUnit\Framework\TestCase;
 
-class MainTest extends TestCase
+class MainTest extends RuntimeTestCase
 {
+    public function setUp(): void
+    {
+        parent::setUp();
+
+        putenv('LAMBDA_TASK_ROOT=' . __DIR__);
+        putenv('_HANDLER=console.php');
+    }
+
     public function test startup hook is called()
     {
         Bref::beforeStartup(function () {
@@ -18,4 +28,53 @@ public function test startup hook is called()
         $this->expectExceptionMessage('This should be called');
         Main::run();
     }
+
+    public function test happy path()
+    {
+        $this->givenAnEvent('');
+
+        try {
+            Main::run();
+        } catch (\Throwable) {
+            // Needed because `run()` is an infinite loop and will fail eventually
+        }
+
+        $this->assertInvocationResult([
+            'exitCode' => 0,
+            'output' => "Hello world!\n",
+        ]);
+    }
+
+    public function test failure()
+    {
+        $this->givenAnEvent('fail');
+
+        try {
+            Main::run();
+        } catch (\Throwable) {
+            // Needed because `run()` is an infinite loop and will fail eventually
+        }
+
+        $this->assertInvocationErrorResult(CommandFailed::class, "Hello world!\nFailure\n");
+    }
+
+    public function test trims output to stay under the 6MB limit of Lambda()
+    {
+        $this->givenAnEvent('flood');
+
+        try {
+            Main::run();
+        } catch (\Throwable) {
+            // Needed because `run()` is an infinite loop and will fail eventually
+        }
+
+        $requests = Server::received();
+        $this->assertCount(2, $requests);
+
+        [, $eventResponse] = $requests;
+        $this->assertLessThan(6 * 1024 * 1024, strlen($eventResponse->getBody()->__toString()));
+        // Check the content of the result can be decoded
+        $result = json_decode($eventResponse->getBody()->__toString(), true, 512, JSON_THROW_ON_ERROR);
+        $this->assertEquals(0, $result['exitCode']);
+    }
 }
diff --git a/tests/ConsoleRuntime/console.php b/tests/ConsoleRuntime/console.php
new file mode 100644
index 000000000..92c6af208
--- /dev/null
+++ b/tests/ConsoleRuntime/console.php
@@ -0,0 +1,14 @@
+<?php declare(strict_types=1);
+
+if (isset($argv[1]) && $argv[1] === 'flood') {
+    // Print 7MB of data to go over the 6MB limit
+    echo str_repeat('x', 7 * 1024 * 1024);
+    exit(0);
+}
+
+echo "Hello world!\n";
+
+if (isset($argv[1]) && $argv[1] === 'fail') {
+    echo "Failure\n";
+    exit(1);
+}
diff --git a/tests/Runtime/LambdaRuntimeTest.php b/tests/Runtime/LambdaRuntimeTest.php
index b27a636f3..885e386c4 100644
--- a/tests/Runtime/LambdaRuntimeTest.php
+++ b/tests/Runtime/LambdaRuntimeTest.php
@@ -15,11 +15,10 @@
 use Bref\Event\Sqs\SqsHandler;
 use Bref\Runtime\LambdaRuntime;
 use Bref\Runtime\ResponseTooBig;
+use Bref\Test\RuntimeTestCase;
 use Bref\Test\Server;
 use Exception;
 use GuzzleHttp\Psr7\Response;
-use JsonException;
-use PHPUnit\Framework\TestCase;
 use Psr\Http\Message\ResponseInterface;
 use Psr\Http\Message\ServerRequestInterface;
 use Psr\Http\Server\RequestHandlerInterface;
@@ -30,21 +29,15 @@
  *
  * The API is mocked using a fake HTTP server.
  */
-class LambdaRuntimeTest extends TestCase
+class LambdaRuntimeTest extends RuntimeTestCase
 {
     private LambdaRuntime $runtime;
 
     protected function setUp(): void
     {
-        ob_start();
-        Server::start();
-        $this->runtime = new LambdaRuntime('localhost:8126', 'phpunit');
-    }
+        parent::setUp();
 
-    protected function tearDown(): void
-    {
-        Server::stop();
-        ob_end_clean();
+        $this->runtime = new LambdaRuntime('localhost:8126', 'phpunit');
     }
 
     public function test basic behavior()
@@ -368,104 +361,4 @@ public function handleEventBridge(EventBridgeEvent $event, Context $context): vo
 
         $this->assertEquals(new EventBridgeEvent($eventData), $handler->event);
     }
-
-    private function givenAnEvent(mixed $event): void
-    {
-        Server::enqueue([
-            new Response( // lambda event
-                200,
-                [
-                    'lambda-runtime-aws-request-id' => '1',
-                    'lambda-runtime-invoked-function-arn' => 'test-function-name',
-                ],
-                json_encode($event, JSON_THROW_ON_ERROR)
-            ),
-            new Response(200), // lambda response accepted
-        ]);
-    }
-
-    private function assertInvocationResult(mixed $result)
-    {
-        $requests = Server::received();
-        $this->assertCount(2, $requests);
-
-        [$eventRequest, $eventResponse] = $requests;
-        $this->assertSame('GET', $eventRequest->getMethod());
-        $this->assertSame('http://localhost:8126/2018-06-01/runtime/invocation/next', $eventRequest->getUri()->__toString());
-        $this->assertSame('POST', $eventResponse->getMethod());
-        $this->assertSame('http://localhost:8126/2018-06-01/runtime/invocation/1/response', $eventResponse->getUri()->__toString());
-        $this->assertEquals($result, json_decode($eventResponse->getBody()->__toString(), true, 512, JSON_THROW_ON_ERROR));
-    }
-
-    private function assertInvocationErrorResult(string $errorClass, string $errorMessage)
-    {
-        $requests = Server::received();
-        $this->assertCount(2, $requests);
-
-        [$eventRequest, $eventResponse] = $requests;
-        $this->assertSame('GET', $eventRequest->getMethod());
-        $this->assertSame('http://localhost:8126/2018-06-01/runtime/invocation/next', $eventRequest->getUri()->__toString());
-        $this->assertSame('POST', $eventResponse->getMethod());
-        $this->assertSame('http://localhost:8126/2018-06-01/runtime/invocation/1/error', $eventResponse->getUri()->__toString());
-
-        // Check the content of the result of the lambda
-        $invocationResult = json_decode($eventResponse->getBody()->__toString(), true, 512, JSON_THROW_ON_ERROR);
-        $this->assertSame([
-            'errorType',
-            'errorMessage',
-            'stackTrace',
-        ], array_keys($invocationResult));
-        $this->assertEquals($errorClass, $invocationResult['errorType']);
-        $this->assertEquals($errorMessage, $invocationResult['errorMessage']);
-        $this->assertIsArray($invocationResult['stackTrace']);
-    }
-
-    private function assertErrorInLogs(string $errorClass, string $errorMessage): void
-    {
-        // Decode the logs from stdout
-        $stdout = $this->getActualOutput();
-
-        [$requestId, $message, $json] = explode("\t", $stdout);
-
-        $this->assertSame('Invoke Error', $message);
-
-        // Check the request ID matches a UUID
-        $this->assertNotEmpty($requestId);
-
-        try {
-            $invocationResult = json_decode($json, true, 512, JSON_THROW_ON_ERROR);
-        } catch (JsonException $e) {
-            $this->fail("Could not decode JSON from logs ({$e->getMessage()}): $json");
-        }
-        unset($invocationResult['previous']);
-        $this->assertSame([
-            'errorType',
-            'errorMessage',
-            'stack',
-        ], array_keys($invocationResult));
-        $this->assertEquals($errorClass, $invocationResult['errorType']);
-        $this->assertStringContainsString($errorMessage, $invocationResult['errorMessage']);
-        $this->assertIsArray($invocationResult['stack']);
-    }
-
-    private function assertPreviousErrorsInLogs(array $previousErrors)
-    {
-        // Decode the logs from stdout
-        $stdout = $this->getActualOutput();
-
-        [, , $json] = explode("\t", $stdout);
-
-        ['previous' => $previous] = json_decode($json, true, 512, JSON_THROW_ON_ERROR);
-        $this->assertCount(count($previousErrors), $previous);
-        foreach ($previous as $index => $error) {
-            $this->assertSame([
-                'errorType',
-                'errorMessage',
-                'stack',
-            ], array_keys($error));
-            $this->assertEquals($previousErrors[$index]['errorClass'], $error['errorType']);
-            $this->assertEquals($previousErrors[$index]['errorMessage'], $error['errorMessage']);
-            $this->assertIsArray($error['stack']);
-        }
-    }
 }
diff --git a/tests/RuntimeTestCase.php b/tests/RuntimeTestCase.php
new file mode 100644
index 000000000..9cf2602b0
--- /dev/null
+++ b/tests/RuntimeTestCase.php
@@ -0,0 +1,128 @@
+<?php declare(strict_types=1);
+
+namespace Bref\Test;
+
+use Bref\Bref;
+use GuzzleHttp\Psr7\Response;
+use JsonException;
+use PHPUnit\Framework\TestCase;
+
+/**
+ * The Lambda Runtime HTTP API is mocked using a fake HTTP server.
+ */
+abstract class RuntimeTestCase extends TestCase
+{
+    protected function setUp(): void
+    {
+        Bref::reset();
+        ob_start();
+        Server::start();
+        putenv('AWS_LAMBDA_RUNTIME_API=localhost:8126');
+    }
+
+    protected function tearDown(): void
+    {
+        Server::stop();
+        ob_end_clean();
+    }
+
+    protected function givenAnEvent(mixed $event): void
+    {
+        Server::enqueue([
+            new Response( // lambda event
+                200,
+                [
+                    'lambda-runtime-aws-request-id' => '1',
+                    'lambda-runtime-invoked-function-arn' => 'test-function-name',
+                ],
+                json_encode($event, JSON_THROW_ON_ERROR)
+            ),
+            new Response(200), // lambda response accepted
+        ]);
+    }
+
+    protected function assertInvocationResult(mixed $result): void
+    {
+        $requests = Server::received();
+        $this->assertCount(2, $requests);
+
+        [$eventRequest, $eventResponse] = $requests;
+        $this->assertSame('GET', $eventRequest->getMethod());
+        $this->assertSame('http://localhost:8126/2018-06-01/runtime/invocation/next', $eventRequest->getUri()->__toString());
+        $this->assertSame('POST', $eventResponse->getMethod());
+        $this->assertSame('http://localhost:8126/2018-06-01/runtime/invocation/1/response', $eventResponse->getUri()->__toString());
+        $this->assertEquals($result, json_decode($eventResponse->getBody()->__toString(), true, 512, JSON_THROW_ON_ERROR));
+    }
+
+    protected function assertInvocationErrorResult(string $errorClass, string $errorMessage): void
+    {
+        $requests = Server::received();
+        $this->assertCount(2, $requests);
+
+        [$eventRequest, $eventResponse] = $requests;
+        $this->assertSame('GET', $eventRequest->getMethod());
+        $this->assertSame('http://localhost:8126/2018-06-01/runtime/invocation/next', $eventRequest->getUri()->__toString());
+        $this->assertSame('POST', $eventResponse->getMethod());
+        $this->assertSame('http://localhost:8126/2018-06-01/runtime/invocation/1/error', $eventResponse->getUri()->__toString());
+
+        // Check the content of the result of the lambda
+        $invocationResult = json_decode($eventResponse->getBody()->__toString(), true, 512, JSON_THROW_ON_ERROR);
+        $this->assertSame([
+            'errorType',
+            'errorMessage',
+            'stackTrace',
+        ], array_keys($invocationResult));
+        $this->assertEquals($errorClass, $invocationResult['errorType']);
+        $this->assertEquals($errorMessage, $invocationResult['errorMessage']);
+        $this->assertIsArray($invocationResult['stackTrace']);
+    }
+
+    protected function assertErrorInLogs(string $errorClass, string $errorMessage): void
+    {
+        // Decode the logs from stdout
+        $stdout = $this->getActualOutput();
+
+        [$requestId, $message, $json] = explode("\t", $stdout);
+
+        $this->assertSame('Invoke Error', $message);
+
+        // Check the request ID matches a UUID
+        $this->assertNotEmpty($requestId);
+
+        try {
+            $invocationResult = json_decode($json, true, 512, JSON_THROW_ON_ERROR);
+        } catch (JsonException $e) {
+            $this->fail("Could not decode JSON from logs ({$e->getMessage()}): $json");
+        }
+        unset($invocationResult['previous']);
+        $this->assertSame([
+            'errorType',
+            'errorMessage',
+            'stack',
+        ], array_keys($invocationResult));
+        $this->assertEquals($errorClass, $invocationResult['errorType']);
+        $this->assertStringContainsString($errorMessage, $invocationResult['errorMessage']);
+        $this->assertIsArray($invocationResult['stack']);
+    }
+
+    protected function assertPreviousErrorsInLogs(array $previousErrors): void
+    {
+        // Decode the logs from stdout
+        $stdout = $this->getActualOutput();
+
+        [, , $json] = explode("\t", $stdout);
+
+        ['previous' => $previous] = json_decode($json, true, 512, JSON_THROW_ON_ERROR);
+        $this->assertCount(count($previousErrors), $previous);
+        foreach ($previous as $index => $error) {
+            $this->assertSame([
+                'errorType',
+                'errorMessage',
+                'stack',
+            ], array_keys($error));
+            $this->assertEquals($previousErrors[$index]['errorClass'], $error['errorType']);
+            $this->assertEquals($previousErrors[$index]['errorMessage'], $error['errorMessage']);
+            $this->assertIsArray($error['stack']);
+        }
+    }
+}
diff --git a/tests/server.js b/tests/server.js
index 5d416feca..bb17a5f75 100644
--- a/tests/server.js
+++ b/tests/server.js
@@ -201,7 +201,7 @@ var GuzzleServer = function(port, log) {
                 that.responses = JSON.parse(request.body);
                 for (var i = 0; i < that.responses.length; i++) {
                     if (that.responses[i].body) {
-                        that.responses[i].body = new Buffer(that.responses[i].body, 'base64');
+                        that.responses[i].body = Buffer.from(that.responses[i].body, 'base64');
                     }
                 }
                 if (that.log) {