Skip to content

Commit e8725a9

Browse files
authored
fix: return HTTP 503 on bootstrap error for /health endpoint (#1961)
* fix: return HTTP 503 on fatal bootstrap error in index.php Uncaught exceptions thrown during bootstrap (before Symfony starts) produced a PHP fatal error page with HTTP 200. Narrow the try-catch to only the env validation block so monitoring systems receive a 503 JSON response consistent with the monitor-bundle DOWN format, without interfering with response sending or kernel termination. Closes #1936 * fix: return 503 JSON from FallbackExceptionListener for health/info routes The fallback exception listener redirected all unhandled exceptions to the feedback page, which would return a 301 instead of an error status for the health and info endpoints. Return a JSON 503 response instead for these paths, consistent with the monitor-bundle DOWN format. * fix: expand try-catch to include kernel boot and request creation Include all pre-output bootstrap steps in the try-catch so that kernel boot failures (e.g. misconfigured bundles) also return 503 instead of a PHP error page with HTTP 200.
1 parent 1cdd470 commit e8725a9

3 files changed

Lines changed: 65 additions & 27 deletions

File tree

public/index.php

Lines changed: 40 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -6,34 +6,47 @@
66

77
require dirname(__DIR__).'/vendor/autoload.php';
88

9-
// Validate required envs
10-
if (!isset($_ENV['APP_ENV'])) {
11-
throw new RuntimeException('APP_ENV environment variable is not defined.');
9+
try {
10+
// Validate required envs
11+
if (!isset($_ENV['APP_ENV'])) {
12+
throw new RuntimeException('APP_ENV environment variable is not defined.');
13+
}
14+
if (!isset($_ENV['APP_SECRET']) || trim($_ENV['APP_SECRET']) === '') {
15+
throw new \RuntimeException('APP_SECRET is missing or empty. Set it in your environment configuration.');
16+
}
17+
if ($_ENV['APP_ENV'] === 'prod' && strlen($_ENV['APP_SECRET']) < 32) {
18+
throw new \RuntimeException('APP_SECRET must be at least 32 characters long in production.');
19+
}
20+
21+
$debug = filter_var($_ENV['APP_DEBUG'], FILTER_VALIDATE_BOOLEAN);
22+
23+
if ($debug) {
24+
umask(0000);
25+
26+
Debug::enable();
27+
}
28+
29+
if ($trustedProxies = $_ENV['TRUSTED_PROXIES'] ?? false) {
30+
Request::setTrustedProxies(explode(',', $trustedProxies), Request::HEADER_X_FORWARDED_ALL ^ Request::HEADER_X_FORWARDED_HOST);
31+
}
32+
33+
if ($trustedHosts = $_ENV['TRUSTED_HOSTS'] ?? false) {
34+
Request::setTrustedHosts(explode(',', $trustedHosts));
35+
}
36+
37+
$kernel = new Kernel($_ENV['APP_ENV'], $debug);
38+
$request = Request::createFromGlobals();
39+
} catch (\Throwable $e) {
40+
http_response_code(503);
41+
header('Content-Type: application/json');
42+
$body = ['status' => 'DOWN'];
43+
if (trim($e->getMessage()) !== '') {
44+
$body['message'] = $e->getMessage();
45+
}
46+
error_log($e->getMessage());
47+
echo json_encode($body) ?: '{"status":"DOWN"}';
48+
exit;
1249
}
13-
if (!isset($_ENV['APP_SECRET']) || trim($_ENV['APP_SECRET']) === '') {
14-
throw new \RuntimeException('APP_SECRET is missing or empty. Set it in your environment configuration.');
15-
}
16-
if ($_ENV['APP_ENV'] === 'prod' && strlen($_ENV['APP_SECRET']) < 32) {
17-
throw new \RuntimeException('APP_SECRET must be at least 32 characters long in production.');
18-
}
19-
$debug = filter_var($_ENV['APP_DEBUG'], FILTER_VALIDATE_BOOLEAN);
20-
21-
if ($debug) {
22-
umask(0000);
23-
24-
Debug::enable();
25-
}
26-
27-
if ($trustedProxies = $_ENV['TRUSTED_PROXIES'] ?? false) {
28-
Request::setTrustedProxies(explode(',', $trustedProxies), Request::HEADER_X_FORWARDED_ALL ^ Request::HEADER_X_FORWARDED_HOST);
29-
}
30-
31-
if ($trustedHosts = $_ENV['TRUSTED_HOSTS'] ?? false) {
32-
Request::setTrustedHosts(explode(',', $trustedHosts));
33-
}
34-
35-
$kernel = new Kernel($_ENV['APP_ENV'], $debug);
36-
$request = Request::createFromGlobals();
3750
$response = $kernel->handle($request);
3851
$response->send();
3952
$kernel->terminate($request, $response);

src/OpenConext/EngineBlockBundle/EventListener/FallbackExceptionListener.php

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
use EngineBlock_Exception;
2222
use OpenConext\EngineBlockBridge\ErrorReporter;
2323
use Psr\Log\LoggerInterface;
24+
use Symfony\Component\HttpFoundation\JsonResponse;
2425
use Symfony\Component\HttpFoundation\RedirectResponse;
2526
use Symfony\Component\HttpKernel\Event\ExceptionEvent;
2627
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
@@ -76,6 +77,15 @@ public function onKernelException(ExceptionEvent $event)
7677
$exception->getMessage()
7778
));
7879

80+
$path = $event->getRequest()->getPathInfo();
81+
if (in_array($path, ['/health', '/internal/health', '/info', '/internal/info'], true)) {
82+
$event->setResponse(new JsonResponse(
83+
['status' => 'DOWN'],
84+
JsonResponse::HTTP_SERVICE_UNAVAILABLE
85+
));
86+
return;
87+
}
88+
7989
if ($exception instanceof EngineBlock_Exception) {
8090
$this->errorReporter->reportError($exception, 'Caught Unhandled EngineBlock_Exception');
8191
} else {

tests/functional/OpenConext/EngineBlockBundle/Controller/MonitorControllerTest.php

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,4 +50,19 @@ public function internal_health_returns_json()
5050
$json = json_decode($response->getContent(), true, 512, JSON_THROW_ON_ERROR);
5151
$this->assertSame(['status' => 'UP'], $json);
5252
}
53+
54+
#[Test]
55+
#[Group('Monitor')]
56+
public function health_returns_json(): void
57+
{
58+
$client = self::createClient();
59+
$client->request('GET', 'https://engine.dev.openconext.local/health');
60+
61+
$response = $client->getResponse();
62+
$this->assertEquals(Response::HTTP_OK, $response->getStatusCode());
63+
$this->assertJson($response->getContent());
64+
65+
$json = json_decode($response->getContent(), true, 512, JSON_THROW_ON_ERROR);
66+
$this->assertSame(['status' => 'UP'], $json);
67+
}
5368
}

0 commit comments

Comments
 (0)