BareGit

Add expected_code config option

Author: MetroWind <chris.corsair@gmail.com>
Date: Sun Sep 20 17:28:15 2026 -0700
Commit: ad23bbd8670e804c1ab07516c7e8c983cc56301f

Changes

diff --git a/config.example.yaml b/config.example.yaml
index a3f2ca4..9bb112e 100644
--- a/config.example.yaml
+++ b/config.example.yaml
@@ -20,6 +20,8 @@ groups:
           unit: minute
         endpoint:
           protocol: HTTP
+          # If set, only this exact response code is considered good.
+          # expected_code: 200
           # If omitted, the service-level URL is used for HTTP probes.
           # url: https://blog.example/
           timeout_second: 5
diff --git a/prd.md b/prd.md
index 0dedc67..856a17c 100644
--- a/prd.md
+++ b/prd.md
@@ -15,7 +15,8 @@ A simple self-hosted service status tracker.
 - It monitors a bunch of endpoints which are defined in the
   config file
 - Endpoints may be monitored by one of the following protocols:
-  * HTTP (check it returns 2xx or 3xx status)
+  * HTTP (check it returns 2xx or 3xx status, unless an exact
+    `expected_code` is configured)
   * TCP or UDP port open
   * ICMP
 - Possible config snippet for endpoint:
@@ -30,6 +31,8 @@ A simple self-hosted service status tracker.
     endpoint:
       # Different protocols may have different set of fields.
       protocol: HTTP
+      # Optional exact response code required for a good status.
+      # expected_code: 200
       # This could be different from the user-facing URL. If this is omitted, use the user-facing URL
       url: https://blog.mws.rocks/
       timeout_second: 5
diff --git a/src/configuration.cpp b/src/configuration.cpp
index db94928..bdf7d55 100644
--- a/src/configuration.cpp
+++ b/src/configuration.cpp
@@ -191,7 +191,17 @@ mw::E<EndpointConfig> parseEndpoint(Node endpoint, std::string_view context)
     {
         ASSIGN_OR_RETURN(auto url,
                          optionalString(endpoint, "url", context));
-        return EndpointConfig{HttpEndpoint{url.value_or("")}};
+        ASSIGN_OR_RETURN(auto expected_code,
+                         optionalInteger<unsigned int>(
+                             endpoint, "expected_code", context));
+        if(expected_code &&
+           (*expected_code < 100 || *expected_code > 599))
+        {
+            return std::unexpected(configError(
+                context, "expected_code must be between 100 and 599"));
+        }
+        return EndpointConfig{HttpEndpoint{
+            url.value_or(""), std::move(expected_code)}};
     }
     if(protocol == "tcp")
     {
diff --git a/src/probe.cpp b/src/probe.cpp
index 0f6edb2..92de548 100644
--- a/src/probe.cpp
+++ b/src/probe.cpp
@@ -50,6 +50,12 @@ mw::E<void> validate(const HttpEndpoint& config)
     {
         return std::unexpected(mw::runtimeError("Expected HTTP(S) URL"));
     }
+    if(config.expected_code &&
+       (*config.expected_code < 100 || *config.expected_code > 599))
+    {
+        return std::unexpected(mw::runtimeError(
+            "Expected HTTP status code must be between 100 and 599"));
+    }
     const auto features = curl_version_info(CURLVERSION_NOW)->features;
     if(!(features & CURL_VERSION_ASYNCHDNS))
     {
@@ -157,7 +163,10 @@ mw::E<ProbeStatus> check(const HttpEndpoint& config,
     {
         return ProbeStatus::BAD;
     }
-    return response->status >= 200 && response->status < 400
+    const auto good = config.expected_code
+        ? response->status == static_cast<int>(*config.expected_code)
+        : response->status >= 200 && response->status < 400;
+    return good
         ? ProbeStatus::GOOD : ProbeStatus::BAD;
 }
 
diff --git a/src/probe.h b/src/probe.h
index d9a336b..6f94f64 100644
--- a/src/probe.h
+++ b/src/probe.h
@@ -4,16 +4,19 @@
 #include <cstdint>
 #include <string>
 #include <memory>
+#include <optional>
 
 #include <mw/error.hpp>
 
 #include "probe_status.h"
 
-/// HTTP configuration; direct 2xx and 3xx responses are healthy.
+/// HTTP configuration; direct 2xx and 3xx responses are healthy by default.
 struct HttpEndpoint
 {
     /// HTTP or HTTPS URL, after applying any service URL fallback.
     std::string url;
+    /// Optional exact HTTP response code required for a healthy result.
+    std::optional<unsigned int> expected_code;
 };
 
 /// TCP connection configuration.
diff --git a/tests/configuration_test.cpp b/tests/configuration_test.cpp
index a52344e..93e40c2 100644
--- a/tests/configuration_test.cpp
+++ b/tests/configuration_test.cpp
@@ -51,6 +51,7 @@ TEST(Configuration, LoadsGroupedProtocolSettings)
         "          unit: minute\n"
         "        endpoint:\n"
         "          protocol: HTTP\n"
+        "          expected_code: 204\n"
         "      ssh:\n"
         "        name: SSH\n"
         "        description: SSH server\n"
@@ -98,6 +99,9 @@ TEST(Configuration, LoadsGroupedProtocolSettings)
     EXPECT_EQ(services[0].interval, std::chrono::minutes(1));
     ASSERT_TRUE(std::holds_alternative<HttpEndpoint>(services[0].endpoint));
     EXPECT_TRUE(std::get<HttpEndpoint>(services[0].endpoint).url.empty());
+    ASSERT_TRUE(std::get<HttpEndpoint>(services[0].endpoint).expected_code);
+    EXPECT_EQ(*std::get<HttpEndpoint>(services[0].endpoint).expected_code,
+              204);
     ASSERT_TRUE(services[0].url);
     EXPECT_EQ(*services[0].url, "https://blog.example/");
 
@@ -135,6 +139,18 @@ TEST(Configuration, RejectsInvalidValues)
         "    services:\n"
         "      x:\n"
         "        name: x\n"
+        "        interval: {value: 1, unit: minute}\n"
+        "        endpoint:\n"
+        "          protocol: HTTP\n"
+        "          url: http://x\n"
+        "          expected_code: 99\n",
+        "worker_count: 1\n"
+        "database_path: db\n"
+        "groups:\n"
+        "  - name: g\n"
+        "    services:\n"
+        "      x:\n"
+        "        name: x\n"
         "        description: x\n"
         "        interval: {value: 1, unit: minute}\n"
         "        endpoint: {protocol: TCP, host: x, port: 0}\n",
diff --git a/tests/probe_test.cpp b/tests/probe_test.cpp
index d3076d5..ff914b0 100644
--- a/tests/probe_test.cpp
+++ b/tests/probe_test.cpp
@@ -174,6 +174,8 @@ TEST(Probe, InvalidConfiguration)
     EXPECT_FALSE(createProbe(HttpEndpoint{"file:///etc/hosts"}));
     EXPECT_FALSE(createProbe(HttpEndpoint{"http://"}));
     EXPECT_FALSE(createProbe(HttpEndpoint{"http://localhost"}, 0s));
+    EXPECT_FALSE(createProbe(HttpEndpoint{"http://localhost", 99}));
+    EXPECT_FALSE(createProbe(HttpEndpoint{"http://localhost", 600}));
     EXPECT_FALSE(createProbe(TcpEndpoint{"localhost", 80}, -1s));
     EXPECT_FALSE(createProbe(TcpEndpoint{
         "localhost", 80}, std::chrono::seconds::max()));
@@ -300,6 +302,28 @@ TEST_F(ProbeTest, HttpStatusCodes)
     }
 }
 
+TEST_F(ProbeTest, HttpExpectedStatusCode)
+{
+    ASSERT_TRUE(bindServer(SOCK_STREAM, AF_INET, true));
+    worker = std::jthread(&ProbeTest::serveHttp, this, 204, false);
+    auto good_probe = createProbe(HttpEndpoint{
+        "http://127.0.0.1:" + std::to_string(port), 204}, 1s);
+    ASSERT_TRUE(good_probe) << good_probe.error().msg();
+    auto good = (*good_probe)->probe();
+    ASSERT_TRUE(good) << good.error().msg();
+    EXPECT_EQ(good->status, ProbeStatus::GOOD);
+    worker.join();
+
+    worker = std::jthread(&ProbeTest::serveHttp, this, 200, false);
+    auto bad_probe = createProbe(HttpEndpoint{
+        "http://127.0.0.1:" + std::to_string(port), 204}, 1s);
+    ASSERT_TRUE(bad_probe) << bad_probe.error().msg();
+    auto bad = (*bad_probe)->probe();
+    ASSERT_TRUE(bad) << bad.error().msg();
+    EXPECT_EQ(bad->status, ProbeStatus::BAD);
+    worker.join();
+}
+
 TEST_F(ProbeTest, HttpTimeout)
 {
     ASSERT_TRUE(bindServer(SOCK_STREAM, AF_INET, true));