Skip to content
Open
23 changes: 15 additions & 8 deletions configs/body_factory/default/.body_factory_info
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,21 @@
# The .body_factory_info file contains descriptive information
# about the error pages in this directory.
#
# Currently, .body_factory_info contains information which
# indicates the character set and natural language of the error
# pages in this directory. For example, to describe Korean
# web pages encoded in the iso-2022-kr character set, you might
# add these lines to .body_factory_info file:
# Supported directives:
#
# Content-Language: kr
# Content-Language Natural language of the error pages (default: en)
# Content-Charset Character encoding (default: utf-8)
# Content-Type MIME type for the response (default: text/html)
#
# For example, to describe Korean web pages encoded in the
# iso-2022-kr character set, you might add these lines:
#
# Content-Language: ko-KR
# Content-Charset: iso-2022-kr
#
# If this file is empty, or only contains comments, the default is
# assumed: English text in the standard utf-8 character set.
# To serve plain text error pages instead of HTML:
#
# Content-Type: text/plain
#
# If this file is empty, or only contains comments, the defaults are
# assumed: English text/html in the utf-8 character set.
39 changes: 39 additions & 0 deletions doc/admin-guide/monitoring/error-messages.en.rst
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,45 @@ it would be used instead of ``cache#read_error`` if there is no ``apache_cache#r
The text for an error message is processed as if it were a :ref:`admin-logging-fields` which
enables customization by values present in the transaction for which the error occurred.

.. _body-factory-info:

Template Set Metadata
---------------------

Each template set directory must contain a ``.body_factory_info`` file for the template set to be
loaded. This file controls the ``Content-Type``, ``Content-Language``, and character set of the
HTTP response headers sent with error pages.

The following directives are supported:

``Content-Language``
The natural language of the error pages. This value is sent in the ``Content-Language`` HTTP
response header. Default: ``en``.

``Content-Charset``
The character encoding of the error pages. This value is appended to the ``Content-Type`` header
as a ``charset`` parameter. Default: ``utf-8``.

Comment on lines +126 to +129
Copy link

Copilot AI Mar 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Content-Charset directive is documented as always being appended to the Content-Type header as a charset parameter, but the current implementation only appends ; charset=... when a Content-Type directive is present and does not already include charset=. Consider updating this section to reflect the actual behavior (including what happens when Content-Type is omitted or already has a charset parameter).

Copilot uses AI. Check for mistakes.
``Content-Type``
The MIME type for the error response. This controls the media type portion of the ``Content-Type``
HTTP response header. Default: ``text/html``.

For example, to serve plain text error pages in English::

Content-Language: en
Content-Charset: utf-8
Content-Type: text/plain

This would produce the response header ``Content-Type: text/plain; charset=utf-8``.

To describe Korean error pages encoded in the ``iso-2022-kr`` character set::

Content-Language: ko-KR
Content-Charset: iso-2022-kr

If the file is empty or contains only comments, the defaults are used: English ``text/html`` in
the ``utf-8`` character set. If the file is absent, the entire template set directory is skipped.

The following table lists the hard-coded Traffic Server HTTP messages,
with corresponding HTTP response codes and customizable files.

Expand Down
3 changes: 2 additions & 1 deletion include/proxy/http/HttpBodyFactory.h
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,7 @@ class HttpBodySetRawData
char *set_name;
char *content_language;
char *content_charset;
char *content_type;
std::unique_ptr<TemplateTable> table_of_pages;
};

Expand Down Expand Up @@ -215,7 +216,7 @@ class HttpBodyFactory
private:
char *fabricate(StrList *acpt_language_list, StrList *acpt_charset_list, const char *type, HttpTransact::State *context,
int64_t *resulting_buffer_length, const char **content_language_return, const char **content_charset_return,
const char **set_return = nullptr);
const char **content_type_return, const char **set_return = nullptr);

const char *determine_set_by_language(StrList *acpt_language_list, StrList *acpt_charset_list);
const char *determine_set_by_host(HttpTransact::State *context);
Expand Down
42 changes: 26 additions & 16 deletions src/proxy/http/HttpBodyFactory.cc
Original file line number Diff line number Diff line change
Expand Up @@ -74,9 +74,10 @@ HttpBodyFactory::fabricate_with_old_api(const char *type, HttpTransact::State *c
size_t content_language_buf_size, char *content_type_out_buf, size_t content_type_buf_size,
int format_size, const char *format)
{
char *buffer = nullptr;
const char *lang_ptr = nullptr;
const char *charset_ptr = nullptr;
char *buffer = nullptr;
const char *lang_ptr = nullptr;
const char *charset_ptr = nullptr;
const char *content_type_ptr = nullptr;
char url[1024];
const char *set = nullptr;
bool found_requested_template = false;
Expand Down Expand Up @@ -145,8 +146,8 @@ HttpBodyFactory::fabricate_with_old_api(const char *type, HttpTransact::State *c
// try to fabricate the desired type of error response //
/////////////////////////////////////////////////////////
if (buffer == nullptr) {
buffer =
fabricate(&acpt_language_list, &acpt_charset_list, type, context, resulting_buffer_length, &lang_ptr, &charset_ptr, &set);
buffer = fabricate(&acpt_language_list, &acpt_charset_list, type, context, resulting_buffer_length, &lang_ptr, &charset_ptr,
&content_type_ptr, &set);
found_requested_template = (buffer != nullptr);
}
/////////////////////////////////////////////////////////////
Expand All @@ -159,7 +160,7 @@ HttpBodyFactory::fabricate_with_old_api(const char *type, HttpTransact::State *c
return nullptr;
}
buffer = fabricate(&acpt_language_list, &acpt_charset_list, "default", context, resulting_buffer_length, &lang_ptr,
&charset_ptr, &set);
&charset_ptr, &content_type_ptr, &set);
}

///////////////////////////////////
Expand All @@ -181,7 +182,11 @@ HttpBodyFactory::fabricate_with_old_api(const char *type, HttpTransact::State *c
if (buffer) { // got an instantiated template
if (!plain_flag) {
snprintf(content_language_out_buf, content_language_buf_size, "%s", lang_ptr);
snprintf(content_type_out_buf, content_type_buf_size, "text/html; charset=%s", charset_ptr);
if (content_type_ptr) {
snprintf(content_type_out_buf, content_type_buf_size, "%s", content_type_ptr);
} else {
snprintf(content_type_out_buf, content_type_buf_size, "text/html; charset=%s", charset_ptr);
}
}

if (enable_logging) {
Expand Down Expand Up @@ -213,8 +218,9 @@ HttpBodyFactory::dump_template_tables(FILE *fp)
for (const auto &it1 : *table_of_sets.get()) {
HttpBodySet *body_set = static_cast<HttpBodySet *>(it1.second);
if (body_set) {
fprintf(fp, "set %s: name '%s', lang '%s', charset '%s'\n", it1.first.c_str(), body_set->set_name,
body_set->content_language, body_set->content_charset);
fprintf(fp, "set %s: name '%s', lang '%s', charset '%s', type '%s'\n", it1.first.c_str(), body_set->set_name,
body_set->content_language, body_set->content_charset,
body_set->content_type ? body_set->content_type : "text/html");

///////////////////////////////////////////
// loop over body-types->body hash table //
Expand Down Expand Up @@ -374,7 +380,7 @@ HttpBodyFactory::~HttpBodyFactory()
char *
HttpBodyFactory::fabricate(StrList *acpt_language_list, StrList *acpt_charset_list, const char *type, HttpTransact::State *context,
int64_t *buffer_length_return, const char **content_language_return, const char **content_charset_return,
const char **set_return)
const char **content_type_return, const char **set_return)
{
char *buffer;
const char *pType = context->txn_conf->body_factory_template_base;
Expand All @@ -386,6 +392,7 @@ HttpBodyFactory::fabricate(StrList *acpt_language_list, StrList *acpt_charset_li
}
*content_language_return = nullptr;
*content_charset_return = nullptr;
*content_type_return = nullptr;

Dbg(dbg_ctl_body_factory, "calling fabricate(type '%s')", type);
*buffer_length_return = 0;
Expand Down Expand Up @@ -442,6 +449,7 @@ HttpBodyFactory::fabricate(StrList *acpt_language_list, StrList *acpt_charset_li

*content_language_return = body_set->content_language;
*content_charset_return = body_set->content_charset;
*content_type_return = body_set->content_type;

// build the custom error page
buffer = t->build_instantiated_buffer(context, buffer_length_return);
Expand Down Expand Up @@ -523,8 +531,9 @@ HttpBodyFactory::determine_set_by_language(std::unique_ptr<BodySetTable> &table_

is_the_default_set = (strcmp(set_name, "default") == 0);

Dbg(dbg_ctl_body_factory_determine_set, " --- SET: %-8s (Content-Language '%s', Content-Charset '%s')", set_name,
body_set->content_language, body_set->content_charset);
Dbg(dbg_ctl_body_factory_determine_set, " --- SET: %-8s (Content-Language '%s', Content-Charset '%s', Content-Type '%s')",
set_name, body_set->content_language, body_set->content_charset,
body_set->content_type ? body_set->content_type : "text/html");

// if no Accept-Language hdr at all, treat as a wildcard that
// slightly prefers "default".
Expand Down Expand Up @@ -894,6 +903,7 @@ HttpBodySet::HttpBodySet()
set_name = nullptr;
content_language = nullptr;
content_charset = nullptr;
content_type = nullptr;

table_of_pages = nullptr;
}
Expand All @@ -903,6 +913,7 @@ HttpBodySet::~HttpBodySet()
ats_free(set_name);
ats_free(content_language);
ats_free(content_charset);
ats_free(content_type);
table_of_pages.reset(nullptr);
}

Expand Down Expand Up @@ -991,16 +1002,15 @@ HttpBodySet::init(char *set, char *dir)
memcpy(value, value_s, value_e - value_s);
value[value_e - value_s] = '\0';

//////////////////////////////////////////////////
// so far, we only support 2 pieces of metadata //
//////////////////////////////////////////////////

if (strcasecmp(name, "Content-Language") == 0) {
ats_free(this->content_language);
this->content_language = ats_strdup(value);
} else if (strcasecmp(name, "Content-Charset") == 0) {
ats_free(this->content_charset);
this->content_charset = ats_strdup(value);
} else if (strcasecmp(name, "Content-Type") == 0) {
ats_free(this->content_type);
this->content_type = ats_strdup(value);
}
}

Expand Down
2 changes: 2 additions & 0 deletions src/proxy/http/HttpTransact.cc
Original file line number Diff line number Diff line change
Expand Up @@ -8359,6 +8359,8 @@ HttpTransact::build_error_response(State *s, HTTPStatus status_code, const char
if (len > 0) {
s->hdr_info.client_response.value_set(static_cast<std::string_view>(MIME_FIELD_CONTENT_TYPE), body_type);
s->hdr_info.client_response.value_set(static_cast<std::string_view>(MIME_FIELD_CONTENT_LANGUAGE), body_language);
ats_free(s->internal_msg_buffer_type);
s->internal_msg_buffer_type = ats_strdup(body_type);
Comment on lines 8359 to +8363
Copy link

Copilot AI Mar 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The PR description mentions fixing HttpSM::setup_internal_transfer to only apply the text/html default when Content-Type is not already present, but setup_internal_transfer() still unconditionally sets Content-Type: text/html when internal_msg_buffer_type is null. If the intent is to preserve a Content-Type set earlier in the response pipeline (e.g., by a plugin), this behavior still overwrites it unless the plugin also populates internal_msg_buffer_type.

Copilot uses AI. Check for mistakes.
} else {
s->hdr_info.client_response.field_delete(static_cast<std::string_view>(MIME_FIELD_CONTENT_TYPE));
s->hdr_info.client_response.field_delete(static_cast<std::string_view>(MIME_FIELD_CONTENT_LANGUAGE));
Expand Down
105 changes: 105 additions & 0 deletions tests/gold_tests/body_factory/body_factory_content_type.test.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
'''
Tests that the Content-Type directive in .body_factory_info is honored
for body factory error responses.
'''
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

import os

Test.Summary = 'Verify Content-Type directive in .body_factory_info controls error response MIME type'
Test.ContinueOnFail = True


class BodyFactoryContentTypeTest:
"""
Test that the Content-Type directive in .body_factory_info is used for
body factory error responses instead of the hardcoded text/html default.

Two scenarios:
1. Default: no Content-Type directive -> text/html; charset=utf-8
2. Custom: Content-Type: text/plain -> text/plain
"""

def __init__(self):
self._setupDefaultTS()
self._setupCustomTS()

def _setupDefaultTS(self):
"""ATS instance with default body factory (no Content-Type directive)."""
self._ts_default = Test.MakeATSProcess("ts_default")
self._ts_default.Disk.records_config.update(
{
'proxy.config.body_factory.enable_customizations': 1,
'proxy.config.url_remap.remap_required': 1,
})
self._ts_default.Disk.remap_config.AddLine('map http://mapped.example.com http://127.0.0.1:65535')

body_factory_dir = self._ts_default.Variables.BODY_FACTORY_TEMPLATE_DIR
info_path = os.path.join(body_factory_dir, 'default', '.body_factory_info')
self._ts_default.Disk.File(info_path).WriteOn("Content-Language: en\nContent-Charset: utf-8\n")

def _setupCustomTS(self):
"""ATS instance with Content-Type: text/plain in .body_factory_info."""
self._ts_custom = Test.MakeATSProcess("ts_custom")
self._ts_custom.Disk.records_config.update(
{
'proxy.config.body_factory.enable_customizations': 1,
'proxy.config.url_remap.remap_required': 1,
})
self._ts_custom.Disk.remap_config.AddLine('map http://mapped.example.com http://127.0.0.1:65535')

body_factory_dir = self._ts_custom.Variables.BODY_FACTORY_TEMPLATE_DIR
info_path = os.path.join(body_factory_dir, 'default', '.body_factory_info')
self._ts_custom.Disk.File(info_path).WriteOn("Content-Type: text/plain\n")

def run(self):
self._testDefaultContentType()
self._testCustomContentType()

def _testDefaultContentType(self):
"""Without Content-Type directive, error responses should use text/html."""
tr = Test.AddTestRun('Default body factory Content-Type is text/html')
tr.Processes.Default.StartBefore(self._ts_default)
tr.Processes.Default.Command = (
f'curl -s -D- -o /dev/null'
f' -H "Host: unmapped.example.com"'
f' http://127.0.0.1:{self._ts_default.Variables.port}/')
tr.Processes.Default.ReturnCode = 0
tr.Processes.Default.TimeOut = 5
tr.Processes.Default.Streams.stdout += Testers.ContainsExpression(
'(?i)Content-Type:\\s*text/html\\s*;\\s*charset=utf-8(?:\\s|\\r|$)',
'Default body factory should produce text/html with charset')
tr.Processes.Default.Streams.stdout += Testers.ContainsExpression('HTTP/1.1 404', 'Unmapped request should get 404')
tr.StillRunningAfter = self._ts_default

def _testCustomContentType(self):
"""With Content-Type: text/plain, error responses should use text/plain."""
tr = Test.AddTestRun('Custom body factory Content-Type is text/plain')
tr.Processes.Default.StartBefore(self._ts_custom)
tr.Processes.Default.Command = (
f'curl -s -D- -o /dev/null'
f' -H "Host: unmapped.example.com"'
f' http://127.0.0.1:{self._ts_custom.Variables.port}/')
tr.Processes.Default.ReturnCode = 0
tr.Processes.Default.TimeOut = 5
tr.Processes.Default.Streams.stdout += Testers.ContainsExpression(
'(?i)Content-Type:\\s*text/plain(?:\\s|\\r|$)', 'Custom body factory should produce text/plain')
tr.Processes.Default.Streams.stdout += Testers.ContainsExpression('HTTP/1.1 404', 'Unmapped request should get 404')
tr.StillRunningAfter = self._ts_custom


BodyFactoryContentTypeTest().run()
2 changes: 1 addition & 1 deletion tests/gold_tests/pluginTest/xdebug/x_remap/out.gold
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ Date: ``
Connection: close
Server: ATS/``
Cache-Control: no-store
Content-Type: text/html
Content-Type: text/html; charset=utf-8
Content-Language: en
X-Remap: from=Not-Found, to=Not-Found
X-Original-Content-Type: text/html; charset=utf-8
Expand Down