From 668799f612e90d5feb2e9b306da0ee87f2396993 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?K=C5=8Dan?= Date: Sun, 29 Mar 2026 16:40:34 -0600 Subject: [PATCH 1/2] Add GET /health endpoint for load balancer and monitoring probes Adds a lightweight health check at GET /health that pings the database via schema->storage->dbh->ping and returns JSON {ok:1} (200) or {ok:0, error:"..."} (503). Bypasses routing, sessions, and auth for minimal overhead. Includes unit tests covering success, ping failure, and connection error scenarios. Co-Authored-By: Claude Opus 4.6 --- lib/App/Yath/Server/Plack.pm | 30 ++++++++ t/unit/App/Yath/Server/Plack.t | 127 ++++++++++++++++++++++++++++++++- 2 files changed, 155 insertions(+), 2 deletions(-) diff --git a/lib/App/Yath/Server/Plack.pm b/lib/App/Yath/Server/Plack.pm index 3b89d7397..98cabe2d4 100644 --- a/lib/App/Yath/Server/Plack.pm +++ b/lib/App/Yath/Server/Plack.pm @@ -201,6 +201,11 @@ sub handle_request { my $self = shift; my ($env) = @_; + # Health check endpoint — bypass routing, sessions, and auth + if ($env->{PATH_INFO} eq '/health' && ($env->{REQUEST_METHOD} || '') eq 'GET') { + return $self->_health_check(); + } + my $schema = $self->schema; my $router = $self->router; my $route = $router->match($env) || {}; @@ -304,6 +309,31 @@ sub handle_request { } +sub _health_check { + my $self = shift; + + my ($ok, $error); + eval { + my $dbh = $self->schema->storage->dbh; + $ok = $dbh->ping ? 1 : 0; + $error = "DB ping returned false" unless $ok; + 1; + } or do { + $ok = 0; + $error = "$@"; + chomp $error; + }; + + my $status = $ok ? 200 : 503; + my $body = $ok ? {ok => 1} : {ok => 0, error => $error}; + + my $res = resp($status); + $res->content_type('application/json'); + $res->body(encode_json($body)); + return $res->finalize; +} + + __END__ =pod diff --git a/t/unit/App/Yath/Server/Plack.t b/t/unit/App/Yath/Server/Plack.t index 55ca79aff..4c469ad3c 100644 --- a/t/unit/App/Yath/Server/Plack.t +++ b/t/unit/App/Yath/Server/Plack.t @@ -1,5 +1,128 @@ -use Test2::V0 -target => 'App::Yath::Server::Plack'; +use Test2::V0; -skip_all "write me"; +BEGIN { + my $ok = eval { require App::Yath::Server::Plack; 1 }; + skip_all "Server dependencies not installed" unless $ok; +} + +use Test2::Harness::Util::JSON qw/decode_json/; +use App::Yath::Server::Plack; + +subtest '_health_check returns 200 when DB ping succeeds' => sub { + my $mock_dbh = mock {} => ( + add => [ping => sub { 1 }], + ); + my $mock_storage = mock {} => ( + add => [dbh => sub { $mock_dbh }], + ); + my $mock_schema = mock {} => ( + add => [ + storage => sub { $mock_storage }, + config => sub { 0 }, + ], + ); + my $mock_config = mock {} => ( + add => [schema => sub { $mock_schema }], + ); + + my $plack = App::Yath::Server::Plack->new(schema_config => $mock_config); + + my $env = { + PATH_INFO => '/health', + REQUEST_METHOD => 'GET', + }; + my $result = $plack->handle_request($env); + + is($result->[0], 200, 'status is 200'); + + my $body = join('', @{$result->[2]}); + my $data = decode_json($body); + is($data, {ok => 1}, 'body contains ok => 1'); +}; + +subtest '_health_check returns 503 when DB ping fails' => sub { + my $mock_dbh = mock {} => ( + add => [ping => sub { 0 }], + ); + my $mock_storage = mock {} => ( + add => [dbh => sub { $mock_dbh }], + ); + my $mock_schema = mock {} => ( + add => [ + storage => sub { $mock_storage }, + config => sub { 0 }, + ], + ); + my $mock_config = mock {} => ( + add => [schema => sub { $mock_schema }], + ); + + my $plack = App::Yath::Server::Plack->new(schema_config => $mock_config); + + my $env = { + PATH_INFO => '/health', + REQUEST_METHOD => 'GET', + }; + my $result = $plack->handle_request($env); + + is($result->[0], 503, 'status is 503'); + + my $body = join('', @{$result->[2]}); + my $data = decode_json($body); + is($data->{ok}, 0, 'ok is 0'); + like($data->{error}, qr/ping/, 'error mentions ping'); +}; + +subtest '_health_check returns 503 when DB connection throws' => sub { + my $mock_storage = mock {} => ( + add => [dbh => sub { die "Connection refused\n" }], + ); + my $mock_schema = mock {} => ( + add => [ + storage => sub { $mock_storage }, + config => sub { 0 }, + ], + ); + my $mock_config = mock {} => ( + add => [schema => sub { $mock_schema }], + ); + + my $plack = App::Yath::Server::Plack->new(schema_config => $mock_config); + + my $env = { + PATH_INFO => '/health', + REQUEST_METHOD => 'GET', + }; + my $result = $plack->handle_request($env); + + is($result->[0], 503, 'status is 503'); + + my $body = join('', @{$result->[2]}); + my $data = decode_json($body); + is($data->{ok}, 0, 'ok is 0'); + like($data->{error}, qr/Connection refused/, 'error captures exception message'); +}; + +subtest '/health only responds to GET' => sub { + my $mock_schema = mock {} => ( + add => [ + config => sub { 0 }, + ], + ); + my $mock_config = mock {} => ( + add => [schema => sub { $mock_schema }], + ); + + my $plack = App::Yath::Server::Plack->new(schema_config => $mock_config); + + # POST /health should fall through to normal routing (and get 404) + my $env = { + PATH_INFO => '/health', + REQUEST_METHOD => 'POST', + }; + my $result = $plack->handle_request($env); + + is($result->[0], 404, 'POST /health returns 404 (not handled by health check)'); +}; done_testing; From 4adfc71e185a85ede63cfb5f36a27887b6af33e1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?K=C5=8Dan?= Date: Mon, 30 Mar 2026 20:03:20 -0600 Subject: [PATCH 2/2] rebase: apply review feedback on #342 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **Summary of changes:** - **Fixed failing `/health only responds to GET` subtest** (CI failure): The original test assumed `POST /health` would cleanly return a 404 PSGI response, but the normal routing path crashes because the test mocks don't cover the full routing/session/controller stack. Rewrote the subtest to verify behavior via a `$health_called` flag — confirms GET triggers the health check (ping called) and POST does not (ping not called), wrapping the POST call in `eval` to tolerate the incomplete mock. This tests the actual intent (method filtering) without requiring the entire Plack routing infrastructure to be mocked. --- t/unit/App/Yath/Server/Plack.t | 27 +++++++++++++++++++++------ 1 file changed, 21 insertions(+), 6 deletions(-) diff --git a/t/unit/App/Yath/Server/Plack.t b/t/unit/App/Yath/Server/Plack.t index 4c469ad3c..7ff6ba4fc 100644 --- a/t/unit/App/Yath/Server/Plack.t +++ b/t/unit/App/Yath/Server/Plack.t @@ -104,8 +104,16 @@ subtest '_health_check returns 503 when DB connection throws' => sub { }; subtest '/health only responds to GET' => sub { + my $health_called = 0; + my $mock_dbh = mock {} => ( + add => [ping => sub { $health_called++; 1 }], + ); + my $mock_storage = mock {} => ( + add => [dbh => sub { $mock_dbh }], + ); my $mock_schema = mock {} => ( add => [ + storage => sub { $mock_storage }, config => sub { 0 }, ], ); @@ -115,14 +123,21 @@ subtest '/health only responds to GET' => sub { my $plack = App::Yath::Server::Plack->new(schema_config => $mock_config); - # POST /health should fall through to normal routing (and get 404) - my $env = { + # GET /health should trigger the health check + my $get_result = $plack->handle_request({ PATH_INFO => '/health', - REQUEST_METHOD => 'POST', - }; - my $result = $plack->handle_request($env); + REQUEST_METHOD => 'GET', + }); + is($get_result->[0], 200, 'GET /health returns 200'); + is($health_called, 1, 'health check was invoked for GET'); - is($result->[0], 404, 'POST /health returns 404 (not handled by health check)'); + # POST /health should NOT trigger the health check — it falls through + $health_called = 0; + my $post_result = eval { $plack->handle_request({ + PATH_INFO => '/health', + REQUEST_METHOD => 'POST', + }) }; + is($health_called, 0, 'health check was NOT invoked for POST'); }; done_testing;