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

1"""Framework-neutral HTTP request context for DataExcept observability. 

2 

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""" 

7 

8from __future__ import annotations 

9 

10import re 

11from collections.abc import Mapping, Sequence 

12 

13from .observability import OperationContext 

14from .trace_context import W3CTraceContext, trace_context_from_mapping 

15 

16__all__ = ["HttpContext", "http_context_from_request"] 

17 

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") 

21 

22 

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 

29 

30 

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 

37 

38 

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 

48 

49 

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) 

62 

63 

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}" 

74 

75 

76class HttpContext: 

77 """Request operation context plus optional W3C propagation context.""" 

78 

79 __slots__ = ("operation_context", "trace_context") 

80 

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 

88 

89 

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. 

101 

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") 

116 

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 ) 

123 

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 ) 

131 

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) 

135 

136 return HttpContext(operation_context, trace_context)