Files
scylladb/test/alternator/test_http_headers.py
Szymon Malewski 6b2fce03f9 alternator: optional stripping of http response headers
In Alternator's HTTP API, response headers can dominate bandwidth for
small payloads. The Server, Date, and Content-Type headers were sent on
every response but many clients never use them.

This patch introduces three Alternator config options:
  - alternator_http_response_server_header,
  - alternator_http_response_disable_date_header,
  - alternator_http_response_disable_content_type_header,
which allow customizing or suppressing the respective HTTP response
headers. All three options support live update (no restart needed).
The Server header is no longer sent by default; the Date and
Content-Type defaults preserve the existing behavior.

The Server and Date header suppression uses Seastar's
set_server_header() and set_generate_date_header() APIs added in
https://github.com/scylladb/seastar/pull/3217. This patch also
fixes deprecation warnings from older Seastar HTTP APIs.

Tests are in test/alternator/test_http_headers.py.

Fixes https://scylladb.atlassian.net/browse/SCYLLADB-70

Closes scylladb/scylladb#28288
2026-05-19 10:47:13 +03:00

165 lines
9.3 KiB
Python

# Copyright 2026-present ScyllaDB
#
# SPDX-License-Identifier: LicenseRef-ScyllaDB-Source-Available-1.1
# Tests for HTTP response header configuration in Alternator.
# Tests the ability to suppress the Server, Date, and Content-Type headers,
# and to customize the Server header value, via configuration options that
# can be updated at runtime.
#
# The default-behavior assertions in each test (headers present with correct
# values) run against both Scylla and Amazon DynamoDB. The configuration-
# manipulation parts are Scylla-only and are skipped when run with --aws.
import time
import pytest
from botocore.exceptions import ClientError
from test.alternator.util import scylla_config_temporary, is_aws
# Test that the Server header value is controlled by alternator_http_response_server_header:
# by default the header is absent (empty config value), a non-empty value
# enables it, and a whitespace-only string suppresses it again.
def test_server_header(dynamodb, test_table_s):
# DynamoDB sends a Server header; Scylla's default is to omit it.
response = test_table_s.get_item(Key={'p': 'test_key'})
headers = response['ResponseMetadata']['HTTPHeaders']
assert ('server' in headers) == is_aws(dynamodb)
with pytest.raises(ClientError) as exc_info:
dynamodb.meta.client.describe_table(TableName='nonexistent_table_xxxxx')
assert ('server' in exc_info.value.response['ResponseMetadata']['HTTPHeaders']) == is_aws(dynamodb)
if is_aws(dynamodb):
return
# Setting a non-empty value enables the header with that value on both
# success and error paths. Nested inside, a whitespace-only string
# suppresses the header again (empty strings can't be stored in the
# config table as they are key attributes).
with scylla_config_temporary(dynamodb, 'alternator_http_response_server_header', 'MyCustomServer/1.0'):
response = test_table_s.get_item(Key={'p': 'test_key'})
headers = response['ResponseMetadata']['HTTPHeaders']
assert headers['server'] == 'MyCustomServer/1.0'
with pytest.raises(ClientError) as exc_info:
dynamodb.meta.client.describe_table(TableName='nonexistent_table_xxxxx')
assert exc_info.value.response['ResponseMetadata']['HTTPHeaders']['server'] == 'MyCustomServer/1.0'
with scylla_config_temporary(dynamodb, 'alternator_http_response_server_header', ' '):
response = test_table_s.get_item(Key={'p': 'test_key'})
headers = response['ResponseMetadata']['HTTPHeaders']
assert 'server' not in headers
with pytest.raises(ClientError) as exc_info:
dynamodb.meta.client.describe_table(TableName='nonexistent_table_xxxxx')
assert 'server' not in exc_info.value.response['ResponseMetadata']['HTTPHeaders']
# sanitize_header_value strips control characters from the value.
# Here an embedded SOH (\x01) between "Server" and "Name" is removed,
# leaving the header set to "ServerName".
with scylla_config_temporary(dynamodb, 'alternator_http_response_server_header', 'Server\x01Name'):
response = test_table_s.get_item(Key={'p': 'test_key'})
headers = response['ResponseMetadata']['HTTPHeaders']
assert headers['server'] == 'ServerName'
# A value consisting entirely of control characters becomes empty
# after sanitization, which suppresses the header.
with scylla_config_temporary(dynamodb, 'alternator_http_response_server_header', '\x01\x02\x03'):
response = test_table_s.get_item(Key={'p': 'test_key'})
headers = response['ResponseMetadata']['HTTPHeaders']
assert 'server' not in headers
# Test that the Date header is present by default and can be suppressed via
# the alternator_http_response_disable_date_header live-update option. After
# re-enabling explicitly (setting to 'false'), the header is present immediately.
def test_config_date_header(dynamodb, test_table_s):
# By default the Date header should be present on both success and error paths.
response = test_table_s.get_item(Key={'p': 'test_key'})
headers = response['ResponseMetadata']['HTTPHeaders']
assert 'date' in headers
with pytest.raises(ClientError) as exc_info:
dynamodb.meta.client.describe_table(TableName='nonexistent_table_xxxxx')
assert 'date' in exc_info.value.response['ResponseMetadata']['HTTPHeaders']
if is_aws(dynamodb):
return
# With the option set to true the Date header should be absent on both paths.
# Nested inside the suppression block, re-enable explicitly and verify the
# header is present again immediately -- no waiting required.
with scylla_config_temporary(dynamodb, 'alternator_http_response_disable_date_header', 'true'):
response = test_table_s.get_item(Key={'p': 'test_key'})
headers = response['ResponseMetadata']['HTTPHeaders']
assert 'date' not in headers
with pytest.raises(ClientError) as exc_info:
dynamodb.meta.client.describe_table(TableName='nonexistent_table_xxxxx')
assert 'date' not in exc_info.value.response['ResponseMetadata']['HTTPHeaders']
with scylla_config_temporary(dynamodb, 'alternator_http_response_disable_date_header', 'false'):
response = test_table_s.get_item(Key={'p': 'test_key'})
headers = response['ResponseMetadata']['HTTPHeaders']
assert 'date' in headers
with pytest.raises(ClientError) as exc_info:
dynamodb.meta.client.describe_table(TableName='nonexistent_table_xxxxx')
assert 'date' in exc_info.value.response['ResponseMetadata']['HTTPHeaders']
# Verifies that the Date header value actually changes over time under normal
# operation, and that the timer continues to run after a suppress-then-re-enable
# cycle. Uses polling rather than fixed sleeps.
@pytest.mark.veryslow
def test_date_header_updates(dynamodb, test_table_s):
def poll_for_date_change(deadline_seconds):
first_date = None
deadline = time.monotonic() + deadline_seconds
while time.monotonic() < deadline:
r = test_table_s.get_item(Key={'p': 'test_key'})
d = r['ResponseMetadata']['HTTPHeaders'].get('date')
assert d is not None, "Date header missing"
if first_date is None:
first_date = d
elif d != first_date:
return True
time.sleep(0.1)
return False
# By default the Date header should be present and change over time
# (Seastar's date header timer fires every ~1 s).
assert poll_for_date_change(5), "Date header did not change -- timer may not be updating"
if is_aws(dynamodb):
return
# Suppress, then re-enable explicitly. Inside the re-enable block the timer
# must be live again: confirm the date value changes within a deadline.
with scylla_config_temporary(dynamodb, 'alternator_http_response_disable_date_header', 'true'):
response = test_table_s.get_item(Key={'p': 'test_key'})
assert 'date' not in response['ResponseMetadata']['HTTPHeaders']
with scylla_config_temporary(dynamodb, 'alternator_http_response_disable_date_header', 'false'):
assert poll_for_date_change(5), "Date header did not change after re-enable -- timer may not have been re-armed"
# Test that the Content-Type header is present by default and can be suppressed
# via the alternator_http_response_disable_content_type_header live-update option.
# Also verifies the error code path (generate_error_reply) which sets
# Content-Type independently from the success path (write_body).
def test_config_content_type_header(dynamodb, test_table_s):
# By default the Content-Type header should be present on both paths.
response = test_table_s.get_item(Key={'p': 'test_key'})
headers = response['ResponseMetadata']['HTTPHeaders']
assert 'content-type' in headers
with pytest.raises(ClientError) as exc_info:
dynamodb.meta.client.describe_table(TableName='nonexistent_table_xxxxx')
assert 'content-type' in exc_info.value.response['ResponseMetadata']['HTTPHeaders']
if is_aws(dynamodb):
return
# Setting the option to true suppresses the header on both success and error
# paths. Nested inside the suppression block, re-enable explicitly and verify
# the header is present again on both paths.
with scylla_config_temporary(dynamodb, 'alternator_http_response_disable_content_type_header', 'true'):
response = test_table_s.get_item(Key={'p': 'test_key'})
headers = response['ResponseMetadata']['HTTPHeaders']
assert 'content-type' not in headers
with pytest.raises(ClientError) as exc_info:
dynamodb.meta.client.describe_table(TableName='nonexistent_table_xxxxx')
assert 'content-type' not in exc_info.value.response['ResponseMetadata']['HTTPHeaders']
with scylla_config_temporary(dynamodb, 'alternator_http_response_disable_content_type_header', 'false'):
response = test_table_s.get_item(Key={'p': 'test_key'})
headers = response['ResponseMetadata']['HTTPHeaders']
assert 'content-type' in headers
with pytest.raises(ClientError) as exc_info:
dynamodb.meta.client.describe_table(TableName='nonexistent_table_xxxxx')
assert 'content-type' in exc_info.value.response['ResponseMetadata']['HTTPHeaders']