"""
Module containing ResponseFinalizer class focusing on putting on the final touches to the response.
The final touches include:
- Content compression.
- Content length calculation and insertion.
- Content encoding determination and insertion.
- etc.
"""
import io
from inspect import isasyncgen
from typing import (
Dict,
Callable,
Optional,
)
from duck.http.content import COMPRESS_STREAMING_RESPONSES
from duck.http.request import HttpRequest
from duck.http.response import (
HttpResponse,
LazyHttpResponse,
ComponentResponse,
StreamingHttpResponse,
StreamingRangeHttpResponse,
HttpRangeNotSatisfiableResponse,
)
from duck.logging.logger import handle_exception as log_failsafe
from duck.settings import SETTINGS
from duck.utils.dateutils import gmt_date
from duck.csp import csp_nonce, csp_nonce_flag
from duck.shortcuts import (
replace_response,
make_response,
to_response,
content_replace,
streaming_content_replace,
)
# Custom templates for predefined responses
# This is a mapping of status codes to a response generating callable
CUSTOM_TEMPLATES: Dict[int, Callable] = SETTINGS["CUSTOM_TEMPLATES"] or {}
if SETTINGS["ENABLE_HTTPS"]:
SECURITY_HEADERS = SETTINGS["SSL_SECURITY_HEADERS"]
else:
SECURITY_HEADERS = SETTINGS["SECURITY_HEADERS"]
[docs]
def set_compressable_iter_content(response):
"""
Modifies the response `iter_content` methods with new functions to compress data as were are iterating.
Note:
- Only use this function if response data is compressable.
- This function modifies both sync and async version of iter_content, i.e.
`iter_content` and `async_iter_content`.
"""
from duck.http.content import (
COMPRESSION_ENCODING,
COMPRESSION_LEVEL,
COMPRESSION_MIMETYPES,
)
content_type = response.get_header("content-type", "")
def iter_and_compress():
"""
Compress content as we are iterating.
"""
for chunk in response.super_iter_content():
if not chunk:
continue # Skip empty or None chunks
# Create a fresh compression wrapper or content object per chunk
content_obj = response.content_obj.__class__() # Clone a fresh object
content_obj.set_content(chunk, content_type=content_type)
content_obj.compression_level = COMPRESSION_LEVEL
content_obj.compression_min_size = 0
content_obj.compression_max_size = len(chunk) + 1
content_obj.compression_mimetypes = COMPRESSION_MIMETYPES
_ = content_obj.compress(COMPRESSION_ENCODING)
compressed_data = content_obj.data
yield compressed_data
async def async_iter_and_compress():
"""
Compress content as we iterate towards it, one chunk at a time.
"""
content = response.super_async_iter_content()
if not isasyncgen(content):
# The content is not an async generator so lets await the coroutine
content = await content
if not isasyncgen(content):
for chunk in content:
if not chunk:
continue # Skip empty or None chunks
# Create a fresh compression wrapper or content object per chunk
content_obj = response.content_obj.__class__() # Clone a fresh object
content_obj.set_content(chunk, content_type=content_type)
content_obj.compression_level = COMPRESSION_LEVEL
content_obj.compression_min_size = 0
content_obj.compression_max_size = len(chunk) + 1
content_obj.compression_mimetypes = COMPRESSION_MIMETYPES
_ = content_obj.compress(COMPRESSION_ENCODING)
compressed_data = content_obj.data
yield compressed_data
else:
async for chunk in content:
if not chunk:
continue # Skip empty or None chunks
# Create a fresh compression wrapper or content object per chunk
content_obj = response.content_obj.__class__() # Clone a fresh object
content_obj.set_content(chunk, content_type=content_type)
content_obj.compression_level = COMPRESSION_LEVEL
content_obj.compression_min_size = 0
content_obj.compression_max_size = len(chunk) + 1
content_obj.compression_mimetypes = COMPRESSION_MIMETYPES
_ = content_obj.compress(COMPRESSION_ENCODING)
compressed_data = content_obj.data
yield compressed_data
# Before assigning check if the old methods are not already set to these above functions
old_iter_content_qualname = response.iter_content.__qualname__
old_async_iter_content_qualname = response.async_iter_content.__qualname__
iter_and_compress_qualname = iter_and_compress.__qualname__
async_iter_and_compress_qualname = async_iter_and_compress.__qualname__
if old_iter_content_qualname != iter_and_compress_qualname:
response.super_iter_content = response.iter_content
response.iter_content = iter_and_compress
if old_async_iter_content_qualname != async_iter_and_compress_qualname:
response.super_async_iter_content = response.async_iter_content
response.async_iter_content = async_iter_and_compress
[docs]
class ResponseFinalizer:
"""
ResponseFinalizer class focusing on putting on the final touches to the response.
"""
@log_failsafe
def do_set_fixed_headers(self, response, request) -> None:
"""
Sets fixed headers from settings, i.e. extra headers, cors headers and security headers.
"""
extra = SETTINGS["EXTRA_HEADERS"] or {}
cors = SETTINGS["CORS_HEADERS"] or {}
security = SECURITY_HEADERS or {}
for h, v in {**security, **cors, **extra}.items():
response.set_header_if_absent(h, v)
# Set CSP header
if request and SETTINGS["ENABLE_HEADERS_SECURITY_POLICY"]:
csp_directives = SETTINGS['CSP_TRUSTED_SOURCES']
nonce = csp_nonce(request)
if csp_directives:
csp_parts = []
for directive, sources in csp_directives.items():
if not sources:
continue
# Build each source string
source_parts = [
f"'nonce-{nonce}'" if i == csp_nonce_flag else i
for i in sources
]
csp_parts.append(f"{directive} {' '.join(source_parts)}")
# Generate the CSP value.
csp_value = "; ".join(csp_parts) + ";"
# Finally, set the header
response.set_header_if_absent("Content-Security-Policy", csp_value)
@log_failsafe
def do_set_connection_mode(self, response, request) -> None:
"""
Sets the response connection mode according to the HTTP version,
client request, and server connection-mode configuration.
HTTP/1.1 uses persistent connections by default, while HTTP/1.0
requires an explicit ``Connection: keep-alive`` header.
The server's configured connection mode can restrict persistent
connections, but cannot force a client to keep a connection open
when the client explicitly requests ``Connection: close``.
Args:
response: HTTP response whose Connection header should be set.
request: HTTP request associated with the response.
"""
server_mode = SETTINGS["CONNECTION_MODE"].lower()
if not request:
response.set_header("Connection", "close")
return
# Initialize some variables.
request_mode = request.connection
http_version = request.http_version
# The client explicitly requested that the connection be closed.
if request_mode == "close":
connection_mode = "close"
# The server does not permit persistent connections.
elif server_mode == "close":
connection_mode = "close"
# HTTP/1.1 connections are persistent by default.
elif http_version == "HTTP/1.1":
connection_mode = "keep-alive"
# HTTP/1.0 requires an explicit keep-alive request.
elif http_version == "HTTP/1.0":
connection_mode = "keep-alive" if request_mode == "keep-alive" else "close"
# Unknown HTTP versions fail closed.
else:
connection_mode = "close"
# Explicitly override any user-supplied Connection header.
response.set_header("Connection", connection_mode)
@log_failsafe
def do_set_extra_headers(self, response, request) -> None:
"""
Sets extra headers like Date, Cache-Control and other internal headers.
"""
from duck.html.components.core.system import LivelyComponentSystem
from duck.logging import logger
response.set_header_if_absent("date", gmt_date())
if not SETTINGS['DEBUG']:
default_cache_control = SECURITY_HEADERS.get("Cache-Control")
# Set the default cache control
response.set_header_if_absent("cache-control", default_cache_control)
else:
# Set the no-cache control
response.set_header("cache-control", "no-cache")
# Set lively owner if Lively component system active
if request and LivelyComponentSystem.is_active():
lively_owner_token = request.META.get(LivelyComponentSystem.OWNER_TOKEN_REQUEST_KEY)
if lively_owner_token is not None:
response.set_cookie(
LivelyComponentSystem.OWNER_COOKIE_KEY,
lively_owner_token,
httponly=True,
samesite="Strict",
max_age=LivelyComponentSystem.OWNER_TOKEN_MAX_AGE,
)
@log_failsafe
def do_content_compression(self, response, request) -> None:
"""
Compresses the content if the client supports it and
if the content is not a streaming response. (if necessary).
"""
from duck.http.content import (
COMPRESSION_ENCODING,
COMPRESSION_LEVEL,
COMPRESSION_MAX_SIZE,
COMPRESSION_MIN_SIZE,
CONTENT_COMPRESSION,
COMPRESSION_MIMETYPES,
)
accept_encoding = request.get_header("accept-encoding", "").lower() if request else ""
supported_encodings = ["gzip", "deflate", "br", "identity"]
if CONTENT_COMPRESSION.get("vary_on", False):
# Patch vary headers
existing_vary_headers = response.get_header("Vary") or ""
if existing_vary_headers:
existing_vary_headers += ", "
# Explitly set vary header
response.set_header("Vary", existing_vary_headers + "Accept-Encoding",)
if (not request or not SETTINGS["ENABLE_CONTENT_COMPRESSION"]
or COMPRESSION_ENCODING not in accept_encoding
or COMPRESSION_ENCODING not in supported_encodings
or response.content_obj.correct_encoding() != "identity"
):
# No need to compress content if correct_encoding is not identity (might already be compressed)
response.set_header("Content-Encoding", response.content_obj.correct_encoding())
return
if not isinstance(response, StreamingHttpResponse):
# Normal HTTP response here.
response.content_obj.compression_level = COMPRESSION_LEVEL
response.content_obj.compression_min_size = COMPRESSION_MIN_SIZE
response.content_obj.compression_max_size = COMPRESSION_MAX_SIZE
response.content_obj.compression_mimetypes = COMPRESSION_MIMETYPES
# Compress the content
compressed = response.content_obj.compress(COMPRESSION_ENCODING)
if compressed:
response.set_header("Content-Encoding", response.content_obj.encoding)
else:
response.set_header("Content-Encoding", response.content_obj.correct_encoding())
else:
# Streaming HTTP response here.
if not COMPRESS_STREAMING_RESPONSES:
# Compressing streaming responses disallowed
return
# A byte-range response's offsets refer to the representation
# being requested, so it must not be transformed WHILE it is
# actually serving a partial range (206). If If-Range failed
# validation and do_set_streaming_range downgraded this to a
# full 200, it's safe to compress like any other streaming
# response — checking status_code rather than isinstance()
# avoids permanently disabling compression on a
# StreamingRangeHttpResponse instance that is no longer
# actually serving a range.
if isinstance(response, StreamingRangeHttpResponse) and response.status_code == 206:
response.set_header("Content-Encoding", "identity")
return
# Get content type
content_type = response.get_header("content-type", "")
# Initialize total stream size
total_stream_size = None
if hasattr(response, "stream") and hasattr(response.stream, "tell") and hasattr(response.stream, "seek"):
# Get the stream size
current_position = response.stream.tell()
try:
response.stream.seek(0, io.SEEK_END)
total_stream_size = response.stream.tell()
finally:
response.stream.seek(current_position)
else:
return # Quit with the compression, no stream!
if total_stream_size is not None:
if total_stream_size < COMPRESSION_MIN_SIZE or total_stream_size > COMPRESSION_MAX_SIZE :
# Total stream size if beyond or below compression limits
return
else:
# Don't compress anything with unknown size
return
# Don't compress HttpProxyResponse instances as doing response.iter_content() for checking if data is compressable
# may make content data inconsistent.
compressable = False # Whether the content is compressable by trying to compress the first chunk
# Check if content is compressable.
for initial_chunk in response.iter_content():
if initial_chunk:
# Create a fresh compression wrapper or content object per chunk
chunk = initial_chunk[:8] # Check compression using first 8 bytes to avoid performance degradation
content_obj = response.content_obj.__class__() # Clone a fresh object
content_obj.set_content(chunk, content_type=content_type)
content_obj.compression_level = COMPRESSION_LEVEL
content_obj.compression_min_size = 0
content_obj.compression_max_size = 8
content_obj.compression_mimetypes = COMPRESSION_MIMETYPES
compressable = content_obj.compress(COMPRESSION_ENCODING) # sets if content is compressable
break
if response.get_header("content-encoding", "identity") == "identity" and compressable:
# Assume compression will not fail, this is is a bit dangerous if compression fails as response might include
# unmatching invalid content content encoding
response.set_header("Content-Encoding", COMPRESSION_ENCODING)
# Modify response iter_content & async_iter_content to new funcs which
# compress data as we are iterating over it.
set_compressable_iter_content(response)
@log_failsafe
def do_set_content_headers(self, response, request) -> None:
"""
Sets the appropriate content headers like `Content-Type`, `Content-Encoding` & `Content-Length` if not set.
Notes:
- If response is an instance of `StreamingHttpResponse`, the `Content-Length` header is removed as a safe measure. The size
of the response content can become unpredictable especially when data is compressed as it is being sent.
"""
from duck.http.core.proxyhandler import HttpProxyResponse
# Set some content headers if not set.
content_length = response.get_header("content-length")
content_encoding = response.get_header("content-encoding")
content_type = response.get_header("content-type")
if not isinstance(response, StreamingHttpResponse):
# Normal HTTP response here
# Set content-length if not set.
if not content_length:
response.set_header("content-length", response.content_length)
# Set content encoding if not set.
if not content_encoding:
response.set_header("content-encoding", response.content_encoding or response.content_obj.correct_encoding())
# Set content-type if not set.
if not content_type:
# Set the predicted content-type from the content object.
response.set_header('content-type', response.content_type)
else:
# Streaming HTTP response here.
# Remove content-length for streaming responses,
# the response content may be unpredictable. This is a safe measure.
if content_length:
# Only ProxyResponse instance is an exception.
if not isinstance(response, HttpProxyResponse):
response.delete_header("content-length")
# Set the content-encoding if not set.
if not content_encoding:
response.set_header("content-encoding", "identity") # default encoding.
# Set the content-type if not set.
if not content_type:
content_type = response.content_obj.content_type
response.set_header("content-type", content_type)
[docs]
@staticmethod
def is_range_valid(
request: HttpRequest,
etag: Optional[str],
last_modified: Optional[str],
) -> bool:
"""
Determines whether a `Range` request should be honored, per `If-Range`.
`If-Range` (RFC 7233 §3.2) lets a client say "give me just this byte
range, but only if the resource I have cached is still exactly this
version — otherwise send me the whole thing." If no `If-Range` header
is present, `Range` is always honored (this returns `True`).
Unlike `If-None-Match`, `If-Range` requires a **strong** comparison —
a weak validator (`W/"..."`) never satisfies it, even if the
underlying value is identical, since a weak ETag only promises
semantic equivalence, not byte-for-byte identity, which is unsafe to
splice a partial range into. If the header value doesn't look like
an ETag (no surrounding quotes), it's treated as an HTTP-date and
compared against `last_modified` instead.
Args:
request: The incoming request, inspected for the `If-Range` header.
etag: The response's current (strong) ETag, or `None`.
last_modified: The response's current Last-Modified value
(HTTP-date string), or `None`. Used only when `If-Range`
carries a date rather than an ETag.
Returns:
`True` if `Range` should be honored (no `If-Range` sent, or it
matches the current strong validator). `False` if the resource
has changed and the full body should be sent instead.
"""
if_range = request.get_header("If-Range")
if if_range is None:
# No If-Range sent — Range is unconditional, always honor it.
return True
if_range = if_range.strip()
# A weak validator can never satisfy If-Range, per RFC 7233 §3.2 —
# fail safe by treating it as non-matching (serve full body).
if if_range.startswith("W/"):
return False
# ETag-shaped value (quoted) — strong comparison against our ETag.
# Our own ETag is always strong (never W/-prefixed), so a direct
# string match is correct here.
if if_range.startswith('"'):
return etag is not None and if_range == etag
# Otherwise treat it as an HTTP-date and compare against Last-Modified.
return last_modified is not None and if_range == last_modified
@log_failsafe
def do_set_streaming_range(self, response, request):
"""
Set streaming range attributes on StreamingRangeHttpResponse.
This method parses the 'Range' header from the request and sets the
start and end positions for partial content streaming.
If an `If-Range` header is present and does not match the response's
current strong validator (`ETag`, falling back to `Last-Modified`),
the `Range` header is ignored entirely and the full resource is
served as `200`, since splicing a partial range against a changed
resource would silently corrupt the client's reconstructed file.
Args:
response (StreamingRangeHttpResponse): The response object to set streaming range on.
request (HttpRequest): The incoming HTTP request containing the 'Range' header.
Raises:
ValueError: If the 'Range' header is malformed or invalid.
"""
if not request:
return # If no request is provided, exit early.
if not isinstance(response, StreamingRangeHttpResponse):
return # Response is incompatible.
# Set the Range header.
range_header = request.get_header('Range')
# Treat the request as rangeless if no Range was sent, or if an
# If-Range precondition was sent but failed to match — in both
# cases we fall through to serving the full 200 body.
honor_range = bool(range_header) and self.is_range_valid(
request, response.etag, response.last_modified
)
if not honor_range:
if response.status_code == 206:
response.payload_obj.parse_status(200) # modify the response to correct status
response.clear_content_range_headers() # clear range headers
return # No Range header, or If-Range failed — full body, no content range headers.
# Parse Range header.
if response.status_code == 200:
# Invalid status (200 OK) instead of (206 Partial Content)
response.payload_obj.parse_status(206) # modify the response to correct status
try:
# Extract start and end positions from the Range header
# Note: Use response.start_pos & end_pos rather than start, end as they are the most recent offsets.
start, end = StreamingRangeHttpResponse.extract_range(range_header)
# Set the start and end positions on the response object
response.parse_range(start, end) # set content range headers (if applicable)
except ValueError as e:
# Replace response data
new_response = make_response(
HttpRangeNotSatisfiableResponse,
extra_context={"exception": e},
)
# Replace response with new data
replace_response(response, new_response)
# Finalize response again as it has new values
# Set do_set_streaming_range & do_content_compression to False to avoid max recursion error
self.finalize_response(
response,
request,
do_set_streaming_range=False,
do_content_compression=False,
)
@log_failsafe
def do_request_response_transformation(self, response: HttpResponse, request: HttpRequest) -> bool:
"""
Transforms the response object by applying request- and response-based modifications.
This includes, but is not limited to, header changes and body alterations.
Behavior Examples:
- If the request method is `HEAD`, the response body is replaced with empty bytes.
- If a matching template is found in the `CUSTOM_TEMPLATES` configuration, the entire response may be replaced.
- If the response is downgraded to `304 Not Modified`, further response processing (e.g. body
generation, streaming setup) should be skipped by the caller.
Args:
response (HttpResponse): The original response to be transformed.
request (HttpRequest): The incoming HTTP request associated with the response.
Returns:
bool: `True` if the caller should continue normal response processing,
`False` if processing should stop here (e.g. the response was
downgraded to `304 Not Modified` and has nothing further to do).
"""
if response:
# Handle 304 here.
if request:
last_modified = response.last_modified
etag = response.etag
# Conditional revalidation only applies to safe methods with a
# cacheable representation (200/206) and a validator to check against.
if (
request.method in ("GET", "HEAD")
and response.status_code in (200, 206)
and (etag is not None or last_modified is not None)
and self.is_not_modified(request, etag, last_modified)
):
# Downgrade to 304 response
self.downgrade_to_not_modified(response)
# Nothing left to do — no body, no custom template, no further transforms.
return False
# HEAD never carries a body, regardless of what produced it.
if request.method == "HEAD":
if isinstance(response, StreamingHttpResponse):
streaming_content_replace(response, stream=[b""])
else:
content_replace(response, b"", new_content_type="use_existing")
# Handle custom status code templates
if response.status_code in CUSTOM_TEMPLATES:
response_callable = CUSTOM_TEMPLATES[response.status_code]
if not callable(response_callable):
raise TypeError(f"Callable required for custom template corresponding to status code of '{response.status_code}' ")
# Parse parameters and obtain the custom template response.
new_response = response_callable(current_response=response, request=request)
try:
new_response = to_response(new_response) # convert or check the validity of the custom response.
except TypeError:
# The value returned by response_generating_callable is not valid
raise TypeError(f"Invalid data returned by the custom template callable corresponding to status code '{response.status_code}' ")
# Replace response with new data
replace_response(response, new_response)
# Normal path — caller should continue processing this response.
return True
[docs]
@staticmethod
def is_not_modified(request: HttpRequest, etag: Optional[str], last_modified: Optional[str]) -> bool:
"""
Determines whether a request's conditional headers match the response's validators.
`If-None-Match` (ETag) is authoritative and checked first, since it is
precise to the nanosecond via `FileIOStream.etag`. `If-Modified-Since`
is only consulted as a fallback when the client sent no `If-None-Match`
— it is never used to override a mismatching ETag, and `Last-Modified`
is never read from response headers here since it is intentionally
never emitted (see `HttpResponseBase.finalize_headers`); this compares
against the response's internal `last_modified` value instead.
Args:
request: The incoming request, inspected for `If-None-Match` / `If-Modified-Since` headers.
etag: The response's current ETag, or `None`.
last_modified: The response's current Last-Modified value (HTTP-date string), or `None`.
Returns:
`True` if the client's cached copy is still valid and a `304`
should be sent instead of the body.
"""
def strip_weak_prefix(etag_value: str) -> str:
"""
Strips the `W/` weak-validator prefix from an ETag value, if present.
Args:
etag_value: A raw ETag token, e.g. `'"abc-123"'` or `'W/"abc-123"'`.
Returns:
The ETag with any leading `W/` removed, e.g. `'"abc-123"'`.
"""
if etag_value.startswith("W/"):
return etag_value[2:]
return etag_value
if_none_match = request.headers.get("If-None-Match")
if if_none_match is not None:
if etag is None:
return False
# Support comma-separated lists and the "*" wildcard per RFC 7232 §3.2.
if if_none_match.strip() == "*":
return True
# Create candidates
candidates = {strip_weak_prefix(tag.strip()) for tag in if_none_match.split(",")}
# Check if etag is in candidates
return etag in candidates
# No ETag sent by the client — fall back to Last-Modified, if we
# have one to compare against.
if_modified_since = request.headers.get("If-Modified-Since")
if if_modified_since is not None and last_modified is not None:
return if_modified_since == last_modified
return False
[docs]
@staticmethod
def downgrade_to_not_modified(response: HttpResponse) -> None:
"""
Converts a fully-built 200/206 response in place into a 304.
Strips the body and any representation-specific headers (since a
`304` has no body), while preserving the validators
(`ETag` is kept; `Last-Modified` remains unemitted as always) so
the client can keep using its cached copy.
Args:
response: The response to downgrade. Mutated in place.
"""
response.status_code = 304
if isinstance(response, StreamingHttpResponse):
streaming_content_replace(response, stream=[b""])
else:
content_replace(response, b"", new_content_type="use_existing")
# A 304 has no representation, so headers describing the body
# (as opposed to the resource) do not apply.
for header in ("Content-Length", "Content-Type", "Content-Range", "Content-Encoding"):
response.delete_header(header, failsafe=True)
# Re-apply validators (ETag only — Last-Modified stays header-less
# by policy) now that headers were cleared above.
response.set_conditional_headers()
[docs]
def finalize_response(
self,
response: HttpResponse,
request: HttpRequest,
do_set_streaming_range: bool = True,
do_content_compression: bool = True,
):
"""
Puts the final touches to the response.
"""
if isinstance(response, LazyHttpResponse):
# Load if response is lazy
response._load()
# All of the following method calls are failsafe meaning failure of any method
# will not affect the execution of other methods, thus an error encountered will be
# logged appropriately. Decorator responsible: @log_failsafe
continue_processing = self.do_request_response_transformation(response, request)
# Continue with next steps
self.do_set_fixed_headers(response, request)
self.do_set_connection_mode(response, request)
self.do_set_extra_headers(response, request)
if not continue_processing:
# Stop further processing at this point - we would have done this immediately
# after do_request_response_transformation but we want other headers to be set.
return
if do_set_streaming_range:
self.do_set_streaming_range(response, request)
# Do content compression in the end.
if do_content_compression:
self.do_content_compression(response, request)
# Lastly review content headers.
self.do_set_content_headers(response, request)
[docs]
class AsyncResponseFinalizer(ResponseFinalizer):
"""
Asynchronous ResponseFinalizer class focusing on putting on the final touches to the response.
"""
@log_failsafe
async def do_content_compression(self, response, request) -> None:
"""
Compresses the content if the client supports it and
if the content is not a streaming response. (if necessary).
"""
from duck.http.content import (
COMPRESSION_ENCODING,
COMPRESSION_LEVEL,
COMPRESSION_MAX_SIZE,
COMPRESSION_MIN_SIZE,
CONTENT_COMPRESSION,
COMPRESSION_MIMETYPES,
)
accept_encoding = request.get_header("accept-encoding", "").lower() if request else ""
supported_encodings = ["gzip", "deflate", "br", "identity"]
if CONTENT_COMPRESSION.get("vary_on", False):
# Patch vary headers
existing_vary_headers = response.get_header("Vary") or ""
if existing_vary_headers:
existing_vary_headers += ", "
# Set vary header explicitly
response.set_header("Vary", existing_vary_headers + "Accept-Encoding")
if (not request or not SETTINGS["ENABLE_CONTENT_COMPRESSION"]
or COMPRESSION_ENCODING not in accept_encoding
or COMPRESSION_ENCODING not in supported_encodings
or response.content_obj.correct_encoding() != "identity"
):
# No need to compress content if correct_encoding is not identity (might already be compressed)
response.set_header("Content-Encoding", response.content_obj.correct_encoding())
return
if not isinstance(response, StreamingHttpResponse):
# Normal HTTP response here.
response.content_obj.compression_level = COMPRESSION_LEVEL
response.content_obj.compression_min_size = COMPRESSION_MIN_SIZE
response.content_obj.compression_max_size = COMPRESSION_MAX_SIZE
response.content_obj.compression_mimetypes = COMPRESSION_MIMETYPES
compressed = response.content_obj.compress(COMPRESSION_ENCODING)
if compressed:
response.set_header("Content-Encoding", response.content_obj.encoding)
else:
response.set_header("Content-Encoding", response.content_obj.correct_encoding())
else:
# Streaming HTTP response here.
if not COMPRESS_STREAMING_RESPONSES:
# Compressing streaming responses disallowed
return
# A byte-range response's offsets refer to the representation
# being requested, so it must not be transformed WHILE it is
# actually serving a partial range (206). If If-Range failed
# validation and do_set_streaming_range downgraded this to a
# full 200, it's safe to compress like any other streaming
# response — checking status_code rather than isinstance()
# avoids permanently disabling compression on a
# StreamingRangeHttpResponse instance that is no longer
# actually serving a range.
if isinstance(response, StreamingRangeHttpResponse) and response.status_code == 206:
response.set_header("Content-Encoding", "identity")
return
content_type = response.get_header("content-type", "")
total_stream_size = None
if hasattr(response, "stream") and hasattr(response.stream, "tell") and hasattr(response.stream, "seek"):
# Get the stream size
current_position = response.stream.tell()
try:
response.stream.seek(0, io.SEEK_END)
total_stream_size = response.stream.tell()
finally:
response.stream.seek(current_position)
else:
if not isinstance(response, ComponentResponse):
return # Quit with the compression, no stream!
if total_stream_size is not None:
if total_stream_size < COMPRESSION_MIN_SIZE or total_stream_size > COMPRESSION_MAX_SIZE :
# Total stream size if beyond or below compression limits
return
else:
# Don't compress anything with unknown size
return
# Don't compress HttpProxyResponse instances as doing response.iter_content() for checking if data is compressable
# may make content data inconsistent.
compressable = False # Whether the content is compressable by trying to compress the first chunk
content = response.async_iter_content()
if not isasyncgen(content):
# The content is not an async generator so lets await it.
content = await content
if not isasyncgen(content):
for chunk in content:
if chunk:
# Create a fresh compression wrapper or content object per chunk
chunk = chunk[:8] # Check compression using first 8 bytes to avoid performance degradation
content_obj = response.content_obj.__class__() # Clone a fresh object
content_obj.set_content(chunk, content_type=content_type)
content_obj.compression_level = COMPRESSION_LEVEL
content_obj.compression_min_size = 0
content_obj.compression_max_size = 8
content_obj.compression_mimetypes = COMPRESSION_MIMETYPES
compressable = content_obj.compress(COMPRESSION_ENCODING) # sets if content is compressable
break
else:
async for chunk in content:
if chunk:
# Create a fresh compression wrapper or content object per chunk
chunk = chunk[:8] # Check compression using first 8 bytes to avoid performance degradationt
content_obj = response.content_obj.__class__() # Clone a fresh object
content_obj.set_content(chunk, content_type=content_type)
content_obj.compression_level = COMPRESSION_LEVEL
content_obj.compression_min_size = 0
content_obj.compression_max_size = 8
content_obj.compression_mimetypes = COMPRESSION_MIMETYPES
compressable = content_obj.compress(COMPRESSION_ENCODING) # sets if content is compressable
break
if response.get_header("content-encoding", "identity") == "identity" and compressable:
# Assume compression will not fail, this is is a bit dangerous if compression fails as response might include
# unmatching invalid content content encoding
response.set_header("Content-Encoding", COMPRESSION_ENCODING)
# Modify response iter_content & async_iter_content to new funcs which
# compress data as we are iterating over it.
set_compressable_iter_content(response)
@log_failsafe
async def do_set_streaming_range(self, response, request):
"""
Set streaming range attributes on StreamingRangeHttpResponse.
This method parses the 'Range' header from the request and sets the
start and end positions for partial content streaming.
If an `If-Range` header is present and does not match the response's
current strong validator (`ETag`, falling back to `Last-Modified`),
the `Range` header is ignored entirely and the full resource is
served as `200`, since splicing a partial range against a changed
resource would silently corrupt the client's reconstructed file.
Args:
response (StreamingRangeHttpResponse): The response object to set streaming range on.
request (HttpRequest): The incoming HTTP request containing the 'Range' header.
Raises:
ValueError: If the 'Range' header is malformed or invalid.
"""
if not request:
return # If no request is provided, exit early.
if not isinstance(response, StreamingRangeHttpResponse):
return
# Set the Range header.
range_header = request.get_header('Range')
# Treat the request as rangeless if no Range was sent, or if an
# If-Range precondition was sent but failed to match — in both
# cases we fall through to serving the full 200 body.
honor_range = bool(range_header) and self.is_range_valid(
request, response.etag, response.last_modified
)
if not honor_range:
if response.status_code == 206:
response.payload_obj.parse_status(200) # modify the response to correct status
response.clear_content_range_headers() # clear range headers
return # No Range header, or If-Range failed — full body, no content range headers.
# Parse Range header.
if response.status_code == 200:
# Invalid status (200 OK) instead of (206 Partial Content)
response.payload_obj.parse_status(206) # modify the response to correct status
try:
# Extract start and end positions from the Range header
# Note: Use response.start_pos & end_pos rather than start, end as they are the most recent offsets.
start, end = StreamingRangeHttpResponse.extract_range(range_header)
# Set the start and end positions on the response object
response.parse_range(start, end) # Set content range headers (if applicable)
except ValueError as e:
# Replace response data
new_response = make_response(
HttpRangeNotSatisfiableResponse,
extra_context={"exception": e},
)
# Replace response with new data
replace_response(response, new_response)
# Finalize response again as it has new values
# Set do_set_streaming_range & do_content_compression to False to avoid max recursion error
await self.finalize_response(
response,
request,
do_set_streaming_range=False,
do_content_compression=False,
)
[docs]
async def finalize_response(
self,
response: HttpResponse,
request: HttpRequest,
do_set_streaming_range: bool = True,
do_content_compression: bool = True,
):
"""
Puts the final touches to the response.
"""
if isinstance(response, LazyHttpResponse):
# Load if response is lazy
await response._async_load()
# All of the following method calls are failsafe meaning failure of any method
# will not affect the execution of other methods, thus an error encountered will be
# logged appropriately. Decorator responsible: @log_failsafe
# NOTE: do_request_response_transformation already handles 304 downgrading,
# HEAD body-stripping, and custom status-code templates — kept in sync with
# the sync class rather than duplicating HEAD-handling here.
continue_processing = self.do_request_response_transformation(response, request)
self.do_set_fixed_headers(response, request)
self.do_set_connection_mode(response, request)
self.do_set_extra_headers(response, request)
if not continue_processing:
# Stop further processing at this point - we would have done this immediately
# after do_request_response_transformation but we want other headers to be set.
return
if do_set_streaming_range:
# This implementation needs to be awaited, it uses some asynchronous implementations.
await self.do_set_streaming_range(response, request)
# Do content compression in the end.
if do_content_compression:
await self.do_content_compression(response, request)
# Lastly review content headers.
self.do_set_content_headers(response, request)
# Set & initialize response finalizers
response_finalizer = ResponseFinalizer()
async_response_finalizer = AsyncResponseFinalizer()