diff --git a/core/functional_tests/CMakeLists.txt b/core/functional_tests/CMakeLists.txt
index 85d2d9353eb7..c1561992afeb 100644
--- a/core/functional_tests/CMakeLists.txt
+++ b/core/functional_tests/CMakeLists.txt
@@ -14,6 +14,9 @@ add_dependencies(${PROJECT_NAME} ${PROJECT_NAME}-early-monitor-port-open)
add_subdirectory(dynamic_configs)
add_dependencies(${PROJECT_NAME} ${PROJECT_NAME}-dynamic-configs)
+add_subdirectory(error_pages)
+add_dependencies(${PROJECT_NAME} ${PROJECT_NAME}-error-pages)
+
add_subdirectory(graceful_shutdown)
add_dependencies(${PROJECT_NAME} ${PROJECT_NAME}-graceful-shutdown)
diff --git a/core/functional_tests/error_pages/CMakeLists.txt b/core/functional_tests/error_pages/CMakeLists.txt
new file mode 100644
index 000000000000..d0ac61bc5988
--- /dev/null
+++ b/core/functional_tests/error_pages/CMakeLists.txt
@@ -0,0 +1,6 @@
+project(userver-core-tests-error-pages CXX)
+
+add_executable(${PROJECT_NAME} "main.cpp")
+target_link_libraries(${PROJECT_NAME} userver::core)
+
+userver_testsuite_add_simple()
diff --git a/core/functional_tests/error_pages/index.html b/core/functional_tests/error_pages/index.html
new file mode 100644
index 000000000000..c7b934f87c4e
--- /dev/null
+++ b/core/functional_tests/error_pages/index.html
@@ -0,0 +1 @@
+
Nothing to see here.
diff --git a/core/functional_tests/error_pages/main.cpp b/core/functional_tests/error_pages/main.cpp
new file mode 100644
index 000000000000..a1060d6b0eb9
--- /dev/null
+++ b/core/functional_tests/error_pages/main.cpp
@@ -0,0 +1,28 @@
+#include
+
+#include
+#include
+#include
+#include
+
+namespace functional_tests {
+
+/// The only handler of the service: everything else ends up on the failsafe
+/// path, where the error pages are applied.
+class HelloHandler final : public server::handlers::HttpHandlerBase {
+public:
+ static constexpr std::string_view kName = "handler-hello";
+
+ using HttpHandlerBase::HttpHandlerBase;
+
+ std::string HandleRequestThrow(const server::http::HttpRequest&, server::request::RequestContext&) const override {
+ return "Hello world!\n";
+ }
+};
+
+} // namespace functional_tests
+
+int main(int argc, char* argv[]) {
+ const auto component_list = components::MinimalServerComponentList().Append();
+ return utils::DaemonMain(argc, argv, component_list);
+}
diff --git a/core/functional_tests/error_pages/static_config.yaml b/core/functional_tests/error_pages/static_config.yaml
new file mode 100644
index 000000000000..80bfbc1920c6
--- /dev/null
+++ b/core/functional_tests/error_pages/static_config.yaml
@@ -0,0 +1,41 @@
+components_manager:
+ task_processors:
+ main-task-processor:
+ worker_threads: 4
+
+ fs-task-processor:
+ worker_threads: 2
+
+ default_task_processor: main-task-processor
+
+ components:
+ logging:
+ fs-task-processor: fs-task-processor
+ loggers:
+ default:
+ file_path: '@stderr'
+ level: debug
+ overflow_behavior: discard
+
+ server:
+ listener:
+ port: 8080
+ task_processor: main-task-processor
+ # [error pages]
+ error-pages:
+ - statuses: [400, 404, 405]
+ status: 200
+ # A relative path is resolved against the working directory
+ # of the service; the tests make this one absolute.
+ body-path: index.html
+ headers:
+ Content-Type: text/html
+ X-Powered-By: userver
+ - statuses: [414]
+ body: 'the URI is too long'
+ # [error pages]
+
+ handler-hello:
+ path: /hello
+ method: GET
+ task_processor: main-task-processor
diff --git a/core/functional_tests/error_pages/tests/conftest.py b/core/functional_tests/error_pages/tests/conftest.py
new file mode 100644
index 000000000000..ef29dff3542a
--- /dev/null
+++ b/core/functional_tests/error_pages/tests/conftest.py
@@ -0,0 +1,18 @@
+import pytest
+
+pytest_plugins = ['pytest_userver.plugins.core']
+
+USERVER_CONFIG_HOOKS = ['error_pages_config_hook']
+
+
+@pytest.fixture(scope='session')
+def error_pages_config_hook(service_source_dir):
+ """Makes the 'body-path' of the error page absolute."""
+
+ def _patch_config(config_yaml, config_vars):
+ listener = config_yaml['components_manager']['components']['server']['listener']
+ for page in listener['error-pages']:
+ if 'body-path' in page:
+ page['body-path'] = str(service_source_dir / page['body-path'])
+
+ return _patch_config
diff --git a/core/functional_tests/error_pages/tests/test_error_pages.py b/core/functional_tests/error_pages/tests/test_error_pages.py
new file mode 100644
index 000000000000..57ea8df775ee
--- /dev/null
+++ b/core/functional_tests/error_pages/tests/test_error_pages.py
@@ -0,0 +1,59 @@
+import socket
+
+import pytest
+
+
+@pytest.fixture(scope='session')
+def error_page(service_source_dir) -> str:
+ return (service_source_dir / 'index.html').read_text()
+
+
+async def test_handler_response_is_not_affected(service_client):
+ response = await service_client.get('/hello')
+ assert response.status == 200
+ assert response.text == 'Hello world!\n'
+ assert 'X-Powered-By' not in response.headers
+
+
+async def test_unknown_path(service_client, error_page):
+ response = await service_client.get('/no/such/path')
+ assert response.status == 200
+ assert response.text == error_page
+ assert response.headers['Content-Type'] == 'text/html'
+ assert response.headers['X-Powered-By'] == 'userver'
+
+
+async def test_method_not_allowed(service_client, error_page):
+ response = await service_client.post('/hello')
+ assert response.status == 200
+ assert response.text == error_page
+ assert response.headers['X-Powered-By'] == 'userver'
+
+
+async def test_status_is_kept_if_not_configured(service_client):
+ # The URI is longer than the default 'max_url_size' of 8192 bytes.
+ response = await service_client.get('/' + 'x' * 9000)
+ assert response.status == 414
+ assert response.text == 'the URI is too long'
+
+
+async def test_head_request_has_no_body(service_client, error_page):
+ response = await service_client.request('HEAD', '/no/such/path')
+ assert response.status == 200
+ assert response.content == b''
+ assert response.headers['Content-Length'] == str(len(error_page))
+
+
+# A malformed request is rejected by the parser, before the routing takes
+# place; both layers must end up on the same error page.
+async def test_malformed_request(service_client, service_port, error_page):
+ with socket.create_connection(('localhost', service_port), timeout=10) as sock:
+ sock.sendall(b'FOOBAR / HTTP/1.1\r\nHost: localhost\r\n\r\n')
+ sock.shutdown(socket.SHUT_WR)
+ response = b''
+ while chunk := sock.recv(4096):
+ response += chunk
+
+ assert response.startswith(b'HTTP/1.1 200 OK\r\n'), response
+ assert response.endswith(error_page.encode()), response
+ assert b'\r\nX-Powered-By: userver\r\n' in response
diff --git a/core/functional_tests/http2server/tests/test_low_level.py b/core/functional_tests/http2server/tests/test_low_level.py
index 62f84aec765b..07316851f34b 100644
--- a/core/functional_tests/http2server/tests/test_low_level.py
+++ b/core/functional_tests/http2server/tests/test_low_level.py
@@ -422,6 +422,28 @@ async def test_request_without_path_resets_stream(create_connection, service_cli
assert stream_id == 3
+async def test_unknown_method_is_a_bad_request(create_connection, service_client):
+ await service_client.update_server_state()
+ async with create_connection() as (sock, conn):
+ # An unsupported ':method' makes the request malformed; it must not
+ # affect the rest of the connection (RFC 9113, 8.1.1).
+ headers = [(':method', 'TRACE')] + PSEUDO_HEADERS[1:]
+ stream_id = conn.get_next_available_stream_id()
+ conn.send_headers(stream_id, headers, end_stream=True)
+ await sock.sendall(conn.data_to_send())
+
+ events = []
+ while not any(isinstance(event, h2.events.ResponseReceived) for event in events):
+ events += await utils.send_and_receive(sock, conn)
+ response = next(event for event in events if isinstance(event, h2.events.ResponseReceived))
+ assert dict(response.headers)[b':status'] == b'400'
+
+ stream_id = conn.get_next_available_stream_id()
+ conn.send_headers(stream_id, DEFAULT_HEADERS, end_stream=True)
+ await sock.sendall(conn.data_to_send())
+ await utils.receive_simple_response(sock, conn)
+
+
async def test_single_reset_keeps_connection_usable(
create_connection,
monitor_client,
diff --git a/core/src/server/component.yaml b/core/src/server/component.yaml
index dc85f4307afd..aaec9a9732f8 100644
--- a/core/src/server/component.yaml
+++ b/core/src/server/component.yaml
@@ -151,6 +151,52 @@ properties:
description: whether to write handler statistics
default: true
+ error-pages:
+ type: array
+ description: |
+ substitute responses for the errors that the server reports by itself, i.e. for the requests that
+ never reach a handler (unknown path, method not allowed, malformed request, throttling); an analog
+ of the nginx `error_page` directive. Responses produced by handlers are not affected.
+ default: '[] (the default server error responses are used)'
+ items:
+ type: object
+ description: substitute response for a set of error statuses
+ additionalProperties: false
+ required:
+ - statuses
+ properties:
+ statuses:
+ type: array
+ description: error statuses to substitute the response for
+ minItems: 1
+ items:
+ type: integer
+ description: error status
+ minimum: 400
+ maximum: 599
+ status:
+ type: integer
+ description: status to respond with instead of the original one
+ default: the original status is kept
+ minimum: 100
+ maximum: 599
+ body:
+ type: string
+ description: response body; mutually exclusive with 'body-path'
+ default: the server-generated body is kept
+ body-path:
+ type: string
+ description: path to a file with the response body, read at service start; mutually exclusive
+ with 'body'
+ headers:
+ type: object
+ description: headers to set on the response
+ default: '{}'
+ properties: {}
+ additionalProperties:
+ type: string
+ description: header value
+
connection:
type: object
description: connection options
diff --git a/core/src/server/http/error_pages.cpp b/core/src/server/http/error_pages.cpp
new file mode 100644
index 000000000000..9e8200b6ec39
--- /dev/null
+++ b/core/src/server/http/error_pages.cpp
@@ -0,0 +1,150 @@
+#include "error_pages.hpp"
+
+#include
+
+#include
+
+#include
+#include
+#include
+#include
+
+#include
+
+USERVER_NAMESPACE_BEGIN
+
+namespace server::http {
+
+namespace {
+
+// Only the statuses that the server reports by itself can be substituted,
+// and those are always errors.
+constexpr int kMinOriginalStatus = 400;
+constexpr int kMaxOriginalStatus = 599;
+
+constexpr int kMinSubstituteStatus = 100;
+constexpr int kMaxSubstituteStatus = 599;
+
+HttpStatus ParseStatus(const yaml_config::YamlConfig& value, int min_status, int max_status) {
+ const auto status = value.As();
+ if (status < min_status || status > max_status) {
+ throw std::runtime_error(fmt::format(
+ "Invalid HTTP status {} in '{}': expected a value in range [{}, {}]",
+ status,
+ value.GetPath(),
+ min_status,
+ max_status
+ ));
+ }
+ return static_cast(status);
+}
+
+std::optional ParseBody(const yaml_config::YamlConfig& page) {
+ auto body = page["body"].As>();
+ const auto body_path = page["body-path"].As>();
+
+ if (body && body_path) {
+ throw std::runtime_error(
+ fmt::format("Both 'body' and 'body-path' are set in '{}', remove one of them", page.GetPath())
+ );
+ }
+ if (!body_path) {
+ return body;
+ }
+
+ try {
+ return fs::blocking::ReadFileContents(*body_path);
+ } catch (const std::exception& ex) {
+ throw std::runtime_error(fmt::format(
+ "Failed to read the error page body from the file '{}' set in '{}': {}",
+ *body_path,
+ page["body-path"].GetPath(),
+ ex.what()
+ ));
+ }
+}
+
+std::vector> ParseHeaders(const yaml_config::YamlConfig& headers) {
+ std::vector> result;
+ if (headers.IsMissing() || headers.IsNull()) {
+ return result;
+ }
+
+ for (const auto& [name, value] : yaml_config::Items(headers)) {
+ auto header_value = value.As();
+ // Better to fail the start than to throw on every substituted response.
+ try {
+ CheckHeaderName(name);
+ CheckHeaderValue(header_value);
+ } catch (const std::exception& ex) {
+ throw std::runtime_error(fmt::format("Invalid header in '{}': {}", headers.GetPath(), ex.what()));
+ }
+ result.emplace_back(name, std::move(header_value));
+ }
+ return result;
+}
+
+} // namespace
+
+ErrorPages::ErrorPages(std::unordered_map pages)
+ : pages_(std::move(pages))
+{}
+
+const ErrorPage* ErrorPages::Find(HttpStatus status) const noexcept { return utils::FindOrNullptr(pages_, status); }
+
+void ApplyErrorPage(const ErrorPage& page, HttpResponse& response) {
+ if (page.status) {
+ response.SetStatus(*page.status);
+ }
+ if (page.body) {
+ response.SetData(*page.body);
+ }
+ for (const auto& [name, value] : page.headers) {
+ response.SetHeader(name, value);
+ }
+}
+
+ErrorPages Parse(const yaml_config::YamlConfig& value, formats::parse::To) {
+ std::unordered_map pages;
+
+ for (const auto& item : value) {
+ ErrorPage page;
+ if (const auto& status = item["status"]; !status.IsMissing()) {
+ page.status = ParseStatus(status, kMinSubstituteStatus, kMaxSubstituteStatus);
+ }
+ page.body = ParseBody(item);
+ page.headers = ParseHeaders(item["headers"]);
+
+ if (!page.status && !page.body && page.headers.empty()) {
+ throw std::runtime_error(fmt::format(
+ "Error page '{}' would change nothing, set at least one of "
+ "'status', 'body', 'body-path', 'headers'",
+ item.GetPath()
+ ));
+ }
+
+ const auto& statuses = item["statuses"];
+ if (statuses.IsMissing() || statuses.GetSize() == 0) {
+ throw std::runtime_error(fmt::format(
+ "No 'statuses' to substitute the response for are set in the error page '{}'", item.GetPath()
+ ));
+ }
+
+ for (const auto& status : statuses) {
+ const auto original_status = ParseStatus(status, kMinOriginalStatus, kMaxOriginalStatus);
+ if (!pages.emplace(original_status, page).second) {
+ throw std::runtime_error(fmt::format(
+ "HTTP status {} is set in more than one error page, the last one is '{}'",
+ static_cast(original_status),
+ item.GetPath()
+ ));
+ }
+ }
+ }
+
+ return ErrorPages{std::move(pages)};
+}
+
+} // namespace server::http
+
+USERVER_NAMESPACE_END
diff --git a/core/src/server/http/error_pages.hpp b/core/src/server/http/error_pages.hpp
new file mode 100644
index 000000000000..b55aa3bb4ce4
--- /dev/null
+++ b/core/src/server/http/error_pages.hpp
@@ -0,0 +1,49 @@
+#pragma once
+
+#include
+#include
+#include
+#include
+#include
+
+#include
+#include
+
+USERVER_NAMESPACE_BEGIN
+
+namespace server::http {
+
+class HttpResponse;
+
+/// A substitute response for the errors that the server reports by itself,
+/// an analog of the nginx `error_page` directive.
+struct ErrorPage final {
+ /// Status to respond with instead of the original one.
+ std::optional status;
+ /// Body to respond with instead of the server-generated one.
+ std::optional body;
+ /// Headers to set on the response, in the configuration order.
+ std::vector> headers;
+};
+
+/// Error pages of a single listener, indexed by the original error status.
+class ErrorPages final {
+public:
+ ErrorPages() = default;
+ explicit ErrorPages(std::unordered_map pages);
+
+ /// @returns the page configured for `status`, or `nullptr` if there is none.
+ const ErrorPage* Find(HttpStatus status) const noexcept;
+
+private:
+ std::unordered_map pages_;
+};
+
+/// Overrides the status, the body and the headers of `response` as `page` says.
+void ApplyErrorPage(const ErrorPage& page, HttpResponse& response);
+
+ErrorPages Parse(const yaml_config::YamlConfig& value, formats::parse::To);
+
+} // namespace server::http
+
+USERVER_NAMESPACE_END
diff --git a/core/src/server/http/error_pages_test.cpp b/core/src/server/http/error_pages_test.cpp
new file mode 100644
index 000000000000..712244c6bac6
--- /dev/null
+++ b/core/src/server/http/error_pages_test.cpp
@@ -0,0 +1,144 @@
+#include
+
+#include
+#include
+
+#include
+
+#include
+#include
+#include
+#include
+#include
+#include
+
+USERVER_NAMESPACE_BEGIN
+
+namespace {
+
+using server::http::HttpStatus;
+
+server::http::ErrorPages ParseErrorPages(std::string_view yaml) {
+ return yaml_config::YamlConfig{formats::yaml::FromString(std::string{yaml}), {}}.As();
+}
+
+constexpr std::string_view kTwoPages = R"(
+- statuses: [404, 405]
+ status: 200
+ body: "index"
+ headers:
+ Content-Type: text/html
+ X-Powered-By: WEETS-WA
+- statuses: [500]
+ body: "oops"
+)";
+
+} // namespace
+
+TEST(ErrorPages, Empty) {
+ const auto pages = ParseErrorPages("[]");
+ EXPECT_EQ(pages.Find(HttpStatus::kNotFound), nullptr);
+}
+
+TEST(ErrorPages, FindsOnlyConfiguredStatuses) {
+ const auto pages = ParseErrorPages(kTwoPages);
+
+ ASSERT_NE(pages.Find(HttpStatus::kNotFound), nullptr);
+ ASSERT_NE(pages.Find(HttpStatus::kMethodNotAllowed), nullptr);
+ ASSERT_NE(pages.Find(HttpStatus::kInternalServerError), nullptr);
+ EXPECT_EQ(pages.Find(HttpStatus::kBadRequest), nullptr);
+ EXPECT_EQ(pages.Find(HttpStatus::kOk), nullptr);
+}
+
+TEST(ErrorPages, ParsesFields) {
+ const auto pages = ParseErrorPages(kTwoPages);
+
+ const auto* page = pages.Find(HttpStatus::kMethodNotAllowed);
+ ASSERT_NE(page, nullptr);
+ ASSERT_TRUE(page->status);
+ EXPECT_EQ(*page->status, HttpStatus::kOk);
+ ASSERT_TRUE(page->body);
+ EXPECT_EQ(*page->body, "index");
+ // The headers keep the configuration order.
+ EXPECT_THAT(
+ page->headers,
+ testing::ElementsAre(testing::Pair("Content-Type", "text/html"), testing::Pair("X-Powered-By", "WEETS-WA"))
+ );
+
+ const auto* server_error_page = pages.Find(HttpStatus::kInternalServerError);
+ ASSERT_NE(server_error_page, nullptr);
+ EXPECT_FALSE(server_error_page->status);
+ ASSERT_TRUE(server_error_page->body);
+ EXPECT_EQ(*server_error_page->body, "oops");
+ EXPECT_TRUE(server_error_page->headers.empty());
+}
+
+TEST(ErrorPages, BodyFromFile) {
+ auto file = fs::blocking::TempFile::Create();
+ constexpr std::string_view kContents = "from file";
+ fs::blocking::RewriteFileContents(file.GetPath(), kContents);
+
+ const auto pages = ParseErrorPages(fmt::format("- statuses: [404]\n body-path: {}\n", file.GetPath()));
+
+ const auto* page = pages.Find(HttpStatus::kNotFound);
+ ASSERT_NE(page, nullptr);
+ ASSERT_TRUE(page->body);
+ EXPECT_EQ(*page->body, kContents);
+}
+
+TEST(ErrorPages, RejectsInvalidConfigs) {
+ // A page that would change nothing is a configuration mistake.
+ EXPECT_THROW(ParseErrorPages("- statuses: [404]"), std::runtime_error);
+ EXPECT_THROW(ParseErrorPages("- statuses: []\n status: 200"), std::runtime_error);
+ EXPECT_THROW(ParseErrorPages("- status: 200"), std::runtime_error);
+ // 'body' and 'body-path' are mutually exclusive.
+ EXPECT_THROW(ParseErrorPages("- statuses: [404]\n body: a\n body-path: /dev/null"), std::runtime_error);
+ EXPECT_THROW(ParseErrorPages("- statuses: [404]\n body-path: /no/such/file"), std::runtime_error);
+ // Only errors are reported by the server itself, so only they can be substituted.
+ EXPECT_THROW(ParseErrorPages("- statuses: [200]\n status: 204"), std::runtime_error);
+ EXPECT_THROW(ParseErrorPages("- statuses: [600]\n status: 204"), std::runtime_error);
+ EXPECT_THROW(ParseErrorPages("- statuses: [404]\n status: 42"), std::runtime_error);
+ // A header that HttpResponse would reject must be rejected at start.
+ EXPECT_THROW(ParseErrorPages("- statuses: [404]\n headers:\n 'Bad Name': v"), std::runtime_error);
+ EXPECT_THROW(ParseErrorPages("- statuses: [404]\n headers:\n Name: \"bad\\rvalue\""), std::runtime_error);
+ // A status must not be claimed by two pages.
+ EXPECT_THROW(
+ ParseErrorPages("- statuses: [404, 405]\n status: 200\n- statuses: [405]\n status: 201"), std::runtime_error
+ );
+}
+
+UTEST(ErrorPages, ApplyOverridesStatusBodyAndHeaders) {
+ server::request::ResponseDataAccounter accounter;
+ const auto request = server::http::HttpRequestBuilder{accounter}.Build();
+ auto& response = request->GetHttpResponse();
+ response.SetStatus(HttpStatus::kMethodNotAllowed);
+ response.SetData("method not allowed");
+
+ const auto pages = ParseErrorPages(kTwoPages);
+ const auto* page = pages.Find(HttpStatus::kMethodNotAllowed);
+ ASSERT_NE(page, nullptr);
+ server::http::ApplyErrorPage(*page, response);
+
+ EXPECT_EQ(response.GetStatus(), HttpStatus::kOk);
+ EXPECT_EQ(response.GetData(), "index");
+ EXPECT_EQ(response.GetHeader("Content-Type"), "text/html");
+ EXPECT_EQ(response.GetHeader("X-Powered-By"), "WEETS-WA");
+}
+
+UTEST(ErrorPages, ApplyKeepsWhatIsNotConfigured) {
+ server::request::ResponseDataAccounter accounter;
+ const auto request = server::http::HttpRequestBuilder{accounter}.Build();
+ auto& response = request->GetHttpResponse();
+ response.SetStatus(HttpStatus::kInternalServerError);
+ response.SetData("internal server error");
+
+ const auto pages = ParseErrorPages(kTwoPages);
+ const auto* page = pages.Find(HttpStatus::kInternalServerError);
+ ASSERT_NE(page, nullptr);
+ server::http::ApplyErrorPage(*page, response);
+
+ EXPECT_EQ(response.GetStatus(), HttpStatus::kInternalServerError);
+ EXPECT_EQ(response.GetData(), "oops");
+}
+
+USERVER_NAMESPACE_END
diff --git a/core/src/server/http/header_validation.cpp b/core/src/server/http/header_validation.cpp
new file mode 100644
index 000000000000..7bc41a1653f3
--- /dev/null
+++ b/core/src/server/http/header_validation.cpp
@@ -0,0 +1,75 @@
+#include "header_validation.hpp"
+
+#include
+#include
+#include
+#include
+
+#include
+
+USERVER_NAMESPACE_BEGIN
+
+namespace server::http {
+
+void CheckHeaderName(std::string_view name) {
+ static constexpr auto init = []() {
+ std::array res{}; // zero initialize
+ for (int i = 0; i < 32; i++) {
+ res[i] = 1;
+ }
+ for (int i = 127; i < 256; i++) {
+ res[i] = 1;
+ }
+ for (const unsigned char c : "()<>@,;:\\\"/[]?={} \t") {
+ res[c] = 1;
+ }
+ return res;
+ };
+ static constexpr auto bad_chars = init();
+
+ bool check_failed = false;
+
+ // this gets autovectorized, and we optimize for happy path here
+ for (const char c : name) {
+ const auto code = static_cast(c);
+ check_failed |= bad_chars[code];
+ }
+
+ if (check_failed) {
+ // in a presumably rare scenarios of the check failing we do a second loop
+ for (const char c : name) {
+ const auto code = static_cast(c);
+ if (bad_chars[code]) {
+ throw std::runtime_error(
+ fmt::format("invalid character in header name: '{}' (#{}), full header name: {}", c, code, name)
+ );
+ }
+ }
+ }
+}
+
+void CheckHeaderValue(std::string_view value) {
+ bool check_failed = false;
+
+ // this gets autovectorized, and we optimize for happy path here
+ for (const char c : value) {
+ auto code = static_cast(c);
+ check_failed |= code < 32 || code == 127;
+ }
+
+ if (check_failed) {
+ // in a presumably rare scenarios of the check failing we do a second loop
+ for (const char c : value) {
+ auto code = static_cast(c);
+ if (code < 32 || code == 127) {
+ throw std::runtime_error(
+ std::string("invalid character in header value: '") + c + "' (#" + std::to_string(code) + ")"
+ );
+ }
+ }
+ }
+}
+
+} // namespace server::http
+
+USERVER_NAMESPACE_END
diff --git a/core/src/server/http/header_validation.hpp b/core/src/server/http/header_validation.hpp
new file mode 100644
index 000000000000..e3d40eb071a0
--- /dev/null
+++ b/core/src/server/http/header_validation.hpp
@@ -0,0 +1,19 @@
+#pragma once
+
+#include
+
+USERVER_NAMESPACE_BEGIN
+
+namespace server::http {
+
+/// @throws std::runtime_error if `name` has a character that RFC 9110 does not
+/// allow in a header name.
+void CheckHeaderName(std::string_view name);
+
+/// @throws std::runtime_error if `value` has a character that RFC 9110 does not
+/// allow in a header value.
+void CheckHeaderValue(std::string_view value);
+
+} // namespace server::http
+
+USERVER_NAMESPACE_END
diff --git a/core/src/server/http/http2_session.cpp b/core/src/server/http/http2_session.cpp
index 1ed167b28280..b3fd41da3360 100644
--- a/core/src/server/http/http2_session.cpp
+++ b/core/src/server/http/http2_session.cpp
@@ -171,7 +171,15 @@ int Http2Session::OnHeader(
auto& stream = parser.GetStreamChecked(Stream::Id{frame->hd.stream_id});
auto& ctor = stream.RequestConstructor();
if (hname == USERVER_NAMESPACE::http::headers::k2::kMethod) {
- ctor.SetMethod(HttpMethodFromString(hvalue));
+ try {
+ ctor.SetMethod(HttpMethodFromString(hvalue));
+ } catch (const std::exception& e) {
+ // Leaving the method unset makes the request malformed, so the
+ // stream gets a "400 Bad Request" instead of the whole connection
+ // being torn down (RFC 9113, 8.1.1).
+ LOG_LIMITED_WARNING() << "can't parse the method: " << e;
+ IncStat(parser.stats_.http2_stats.streams_parse_error);
+ }
stream.CheckUrlComplete();
} else if (hname == USERVER_NAMESPACE::http::headers::k2::kPath) {
try {
diff --git a/core/src/server/http/http_request_handler.cpp b/core/src/server/http/http_request_handler.cpp
index 046a9bee752b..adffad7f9a9d 100644
--- a/core/src/server/http/http_request_handler.cpp
+++ b/core/src/server/http/http_request_handler.cpp
@@ -30,10 +30,12 @@ HttpRequestHandler::HttpRequestHandler(
const std::optional& logger_access_component,
const std::optional& logger_access_tskv_component,
bool is_monitor,
- std::string server_name
+ std::string server_name,
+ ErrorPages error_pages
)
: is_monitor_(is_monitor),
server_name_(std::move(server_name)),
+ error_pages_(std::move(error_pages)),
rate_limit_(utils::TokenBucket::MakeUnbounded()),
metrics_(component_context.FindComponent().GetMetricsStorage()),
config_source_(component_context.FindComponent().GetSource())
@@ -57,13 +59,19 @@ engine::TaskWithResult HttpRequestHandler::StartFailsafeTask(std::shared_p
) const {
const auto* handler = http_request->GetHttpHandler();
- return engine::AsyncNoTracing([request = std::move(http_request), handler]() {
+ // Neither the handler nor the middlewares run on this path, so the error
+ // pages are the only way to customize the response the server reports here.
+ return engine::AsyncNoTracing([request = std::move(http_request), handler, error_pages = &error_pages_]() {
request->SetTaskStartTime();
+ auto& response = request->GetHttpResponse();
if (handler) {
handler->ReportMalformedRequest(*request);
}
+ if (const auto* error_page = error_pages->Find(response.GetStatus())) {
+ ApplyErrorPage(*error_page, response);
+ }
request->SetResponseNotifyTime();
- request->GetHttpResponse().SetReady();
+ response.SetReady();
});
}
diff --git a/core/src/server/http/http_request_handler.hpp b/core/src/server/http/http_request_handler.hpp
index eb6a7a9b092b..98d5e8933cd3 100644
--- a/core/src/server/http/http_request_handler.hpp
+++ b/core/src/server/http/http_request_handler.hpp
@@ -12,6 +12,7 @@
#include
#include
+#include "error_pages.hpp"
#include "handler_info_index.hpp"
USERVER_NAMESPACE_BEGIN
@@ -25,7 +26,8 @@ class HttpRequestHandler final : public RequestHandlerBase {
const std::optional& logger_access_component,
const std::optional& logger_access_tskv_component,
bool is_monitor,
- std::string server_name
+ std::string server_name,
+ ErrorPages error_pages
);
using NewRequestHook = std::function)>;
@@ -55,6 +57,7 @@ class HttpRequestHandler final : public RequestHandlerBase {
const bool is_monitor_;
const std::string server_name_;
+ const ErrorPages error_pages_;
NewRequestHook new_request_hook_;
mutable utils::TokenBucket rate_limit_;
std::atomic cc_status_code_{HttpStatus::kTooManyRequests};
diff --git a/core/src/server/http/http_response.cpp b/core/src/server/http/http_response.cpp
index 817366707767..b3905369637b 100644
--- a/core/src/server/http/http_response.cpp
+++ b/core/src/server/http/http_response.cpp
@@ -1,7 +1,5 @@
#include
-#include
-
#include
#include
@@ -18,6 +16,7 @@
#include
#include
+#include
#include
#include
@@ -34,65 +33,6 @@ constexpr std::string_view kKeepAlive = "keep-alive";
const std::string kHostname = hostinfo::blocking::GetRealHostName();
-void CheckHeaderName(std::string_view name) {
- static constexpr auto init = []() {
- std::array res{}; // zero initialize
- for (int i = 0; i < 32; i++) {
- res[i] = 1;
- }
- for (int i = 127; i < 256; i++) {
- res[i] = 1;
- }
- for (const unsigned char c : "()<>@,;:\\\"/[]?={} \t") {
- res[c] = 1;
- }
- return res;
- };
- static constexpr auto bad_chars = init();
-
- bool check_failed = false;
-
- // this gets autovectorized, and we optimize for happy path here
- for (const char c : name) {
- const auto code = static_cast(c);
- check_failed |= bad_chars[code];
- }
-
- if (check_failed) {
- // in a presumably rare scenarios of the check failing we do a second loop
- for (const char c : name) {
- const auto code = static_cast(c);
- if (bad_chars[code]) {
- throw std::runtime_error(
- fmt::format("invalid character in header name: '{}' (#{}), full header name: {}", c, code, name)
- );
- }
- }
- }
-}
-
-void CheckHeaderValue(std::string_view value) {
- bool check_failed = false;
-
- // this gets autovectorized, and we optimize for happy path here
- for (const char c : value) {
- auto code = static_cast(c);
- check_failed |= code < 32 || code == 127;
- }
-
- if (check_failed) {
- // in a presumably rare scenarios of the check failing we do a second loop
- for (const char c : value) {
- auto code = static_cast(c);
- if (code < 32 || code == 127) {
- throw std::runtime_error(
- std::string("invalid character in header value: '") + c + "' (#" + std::to_string(code) + ")"
- );
- }
- }
- }
-}
-
bool IsBodyForbiddenForStatus(server::http::HttpStatus status) {
return status == server::http::HttpStatus::kNoContent || status == server::http::HttpStatus::kNotModified ||
(static_cast(status) >= 100 && static_cast(status) < 200);
diff --git a/core/src/server/net/listener_config.cpp b/core/src/server/net/listener_config.cpp
index fe3771fc4751..825773fc78a9 100644
--- a/core/src/server/net/listener_config.cpp
+++ b/core/src/server/net/listener_config.cpp
@@ -108,6 +108,7 @@ ListenerConfig Parse(const yaml_config::YamlConfig& value, formats::parse::To();
config.handler_defaults = value["handler-defaults"].As();
+ config.error_pages = value["error-pages"].As({});
config.max_connections = value["max_connections"].As(config.max_connections);
config.shards = value["shards"].As>(config.shards);
config.task_processor = value["task_processor"].As>();
diff --git a/core/src/server/net/listener_config.hpp b/core/src/server/net/listener_config.hpp
index d95716ece24e..5a89197be71c 100644
--- a/core/src/server/net/listener_config.hpp
+++ b/core/src/server/net/listener_config.hpp
@@ -12,6 +12,8 @@
#include
#include
+#include
+
#include "connection_config.hpp"
USERVER_NAMESPACE_BEGIN
@@ -41,6 +43,7 @@ struct PortConfig {
struct ListenerConfig {
ConnectionConfig connection_config;
request::HttpRequestConfig handler_defaults;
+ http::ErrorPages error_pages;
int backlog = 1024; // truncated to net.core.somaxconn
size_t max_connections = 32768;
std::optional shards;
diff --git a/core/src/server/server.cpp b/core/src/server/server.cpp
index 6ee2a5c9e2eb..402de35ff768 100644
--- a/core/src/server/server.cpp
+++ b/core/src/server/server.cpp
@@ -66,8 +66,14 @@ void PortInfo::Init(
? component_context.GetTaskProcessor(*listener_config.task_processor)
: engine::current_task::GetTaskProcessor();
- request_handler
- .emplace(component_context, config.logger_access, config.logger_access_tskv, is_monitor, config.server_name);
+ request_handler.emplace(
+ component_context,
+ config.logger_access,
+ config.logger_access_tskv,
+ is_monitor,
+ config.server_name,
+ listener_config.error_pages
+ );
endpoint_info = std::make_shared(listener_config, *request_handler);
diff --git a/scripts/docs/en/userver/http_server.md b/scripts/docs/en/userver/http_server.md
index 5a4c3fceeb9a..93cb26c5b63f 100644
--- a/scripts/docs/en/userver/http_server.md
+++ b/scripts/docs/en/userver/http_server.md
@@ -27,6 +27,7 @@
* @ref scripts/docs/en/userver/tutorial/multipart_service.md "File uploads and multipart/form-data"
* @ref scripts/docs/en/userver/deadline_propagation.md .
* @ref scripts/docs/en/userver/http_server_middlewares.md "Middlewares"
+* Error pages - substitute responses for the errors reported by the server itself.
## Streaming API
@@ -83,6 +84,27 @@ components_manager:
You can set some options specific to `HTTP/2.0` in the `http2-session` section. See docs for these options in components::Server
+## Error pages
+
+Some requests never reach a handler: the URL matches no handler, the method is
+not allowed for the matched handler, the request is malformed, or the request is
+throttled. The server reports such errors by itself, running neither the handler
+nor the @ref scripts/docs/en/userver/http_server_middlewares.md "middlewares",
+so the only way to customize those responses is the `error-pages` option of the
+listener - an analog of the nginx `error_page` directive:
+
+@snippet core/functional_tests/error_pages/static_config.yaml error pages
+
+Each entry substitutes the response for every status it lists in `statuses`:
+`status` replaces the status code, `body` or `body-path` replaces the body, and
+`headers` are added to the response. Whatever is not set is left as is, so the
+first entry above turns "404 Not Found" into "200 OK" with an HTML page, while
+the second one only replaces the body of "414 URI Too Long".
+
+Responses produced by handlers are not affected - use
+@ref scripts/docs/en/userver/http_server_middlewares.md "middlewares" for those.
+
+
## Components
* @ref components::Server "Server"