Coverage for dataexcept/http_context.py: 88%
72 statements
« prev ^ index » next coverage.py v7.16.0, created at 2026-09-27 14:44 +0000
« prev ^ index » next coverage.py v7.16.0, created at 2026-09-27 14:44 +0000
1"""Framework-neutral HTTP request context for DataExcept observability.
3The adapter works with plain header mappings and route templates, so ASGI,
4WSGI, serverless and RPC gateways can use the same logic without becoming
5runtime dependencies of DataExcept.
6"""
8from __future__ import annotations
10import re
11from collections.abc import Mapping, Sequence
13from .observability import OperationContext
14from .trace_context import W3CTraceContext, trace_context_from_mapping
16__all__ = ["HttpContext", "http_context_from_request"]
18_METHOD = re.compile(r"^[A-Z][A-Z0-9!#$%&'*+.^_`|~-]*$")
19_DEFAULT_REQUEST_ID_HEADERS = ("x-request-id", "request-id")
20_DEFAULT_CORRELATION_ID_HEADERS = ("x-correlation-id", "correlation-id")
23def _safe_header_text(value: object) -> str | None:
24 if not isinstance(value, str):
25 return None
26 if not value or "\r" in value or "\n" in value:
27 return None
28 return value
31def _normalized_headers(headers: Mapping[str, object]) -> dict[str, object]:
32 normalized: dict[str, object] = {}
33 for key, value in headers.items():
34 if isinstance(key, str): 34 ↛ 33line 34 didn't jump to line 33 because the condition on line 34 was always true
35 normalized[key.lower()] = value
36 return normalized
39def _first_header(
40 headers: Mapping[str, object],
41 names: Sequence[str],
42) -> str | None:
43 for name in names:
44 value = _safe_header_text(headers.get(name.lower()))
45 if value is not None:
46 return value
47 return None
50def _validate_header_names(names: Sequence[str], field: str) -> tuple[str, ...]:
51 if isinstance(names, (str, bytes)):
52 raise TypeError(f"{field} must be a sequence of header names")
53 result: list[str] = []
54 for name in names:
55 if not isinstance(name, str): 55 ↛ 56line 55 didn't jump to line 56 because the condition on line 55 was never true
56 raise TypeError(f"{field} entries must be strings")
57 stripped = name.strip().lower()
58 if not stripped: 58 ↛ 59line 58 didn't jump to line 59 because the condition on line 58 was never true
59 raise ValueError(f"{field} entries must not be empty")
60 result.append(stripped)
61 return tuple(result)
64def _operation_name(method: str, route: str | None) -> str:
65 if route is None: 65 ↛ 66line 65 didn't jump to line 66 because the condition on line 65 was never true
66 return method
67 if not isinstance(route, str): 67 ↛ 68line 67 didn't jump to line 68 because the condition on line 67 was never true
68 raise TypeError("route must be a string or None")
69 if not route.strip(): 69 ↛ 70line 69 didn't jump to line 70 because the condition on line 69 was never true
70 raise ValueError("route must not be empty")
71 if "?" in route or "#" in route:
72 raise ValueError("route must be a route template without query or fragment")
73 return f"{method} {route}"
76class HttpContext:
77 """Request operation context plus optional W3C propagation context."""
79 __slots__ = ("operation_context", "trace_context")
81 def __init__(
82 self,
83 operation_context: OperationContext,
84 trace_context: W3CTraceContext | None,
85 ) -> None:
86 self.operation_context = operation_context
87 self.trace_context = trace_context
90def http_context_from_request(
91 method: str,
92 *,
93 route: str | None = None,
94 headers: Mapping[str, object] | None = None,
95 system: str | None = "http",
96 component: str | None = None,
97 request_id_headers: Sequence[str] = _DEFAULT_REQUEST_ID_HEADERS,
98 correlation_id_headers: Sequence[str] = _DEFAULT_CORRELATION_ID_HEADERS,
99) -> HttpContext:
100 """Build request observability context from plain HTTP-style inputs.
102 ``route`` should be a low-cardinality route template such as
103 ``/users/{id}``, never a raw request path. Request and correlation IDs are
104 read from configurable headers. W3C trace context is propagated when
105 present, but no trace or span identifiers are generated when it is absent.
106 """
107 if not isinstance(method, str): 107 ↛ 108line 107 didn't jump to line 108 because the condition on line 107 was never true
108 raise TypeError("method must be a string")
109 normalized_method = method.strip().upper()
110 if not _METHOD.fullmatch(normalized_method):
111 raise ValueError("method must be a valid HTTP method token")
112 if headers is None:
113 headers = {}
114 if not isinstance(headers, Mapping):
115 raise TypeError("headers must be a mapping or None")
117 normalized = _normalized_headers(headers)
118 request_names = _validate_header_names(request_id_headers, "request_id_headers")
119 correlation_names = _validate_header_names(
120 correlation_id_headers,
121 "correlation_id_headers",
122 )
124 operation_context = OperationContext(
125 system=system,
126 component=component,
127 operation=_operation_name(normalized_method, route),
128 request_id=_first_header(normalized, request_names),
129 correlation_id=_first_header(normalized, correlation_names),
130 )
132 trace_context = trace_context_from_mapping(normalized)
133 if trace_context is not None:
134 operation_context = trace_context.to_operation_context(operation_context)
136 return HttpContext(operation_context, trace_context)