Skip to content

Commit 336182a

Browse files
mwdd146980claude
andcommitted
Add mock HTTP response (#22677)
* Add MockHTTPResponse for library-agnostic HTTP response mocking MockHTTPResponse implements HTTPResponseProtocol without depending on requests or httpx. It supports the full response API (.json(), .text, .content, .status_code, .headers, .cookies, .elapsed), streaming via iter_content() and iter_lines(), raise_for_status() using HTTPStatusError, and context manager usage. Demonstrates usage by migrating test_authtoken.py from MockResponse to MockHTTPResponse — a drop-in replacement with protocol compliance. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Expand MockHTTPResponse usage to test_openmetrics.py and test_kerberos_unit.py Also adds three missing API surface members discovered during migration: - encoding attribute (accessed by openmetrics mixin before iter_lines) - close() no-op (called by openmetrics mixin after response processing) - Removes _stream_consumed guard so the same instance can be reused across repeated mock.MagicMock(return_value=...) calls Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Reformat with ddev test --fmt * Fix headers mutation and add case-insensitive header dict Two correctness fixes in MockHTTPResponse: 1. When json_data and headers are both provided, the caller's headers dict was mutated in-place via setdefault(). Now copies before modifying. 2. self.headers was a plain dict (case-sensitive). HTTP headers are case-insensitive per RFC 7230 §3.2. Replace with _CaseInsensitiveDict that stores keys lowercased, matching requests.Response behaviour. --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent e0d01da commit 336182a

6 files changed

Lines changed: 306 additions & 36 deletions

File tree

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Add MockHTTPResponse for library-agnostic HTTP response mocking in tests.
Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
# (C) Datadog, Inc. 2026-present
2+
# All rights reserved
3+
# Licensed under a 3-clause BSD style license (see LICENSE)
4+
"""Testing utilities for HTTP client mocking."""
5+
6+
import json
7+
from io import BytesIO
8+
from typing import Any, Iterator
9+
10+
__all__ = ['MockHTTPResponse']
11+
12+
13+
class _CaseInsensitiveDict(dict):
14+
"""Case-insensitive dict for HTTP headers per RFC 7230 §3.2.
15+
16+
Stores keys lowercased so lookup works regardless of the casing used by the
17+
caller or by production code (e.g. 'Content-Type' vs 'content-type').
18+
"""
19+
20+
def __init__(self, data: dict[str, str] | None = None) -> None:
21+
super().__init__()
22+
if data:
23+
for key, value in data.items():
24+
self[key] = value
25+
26+
def __setitem__(self, key: str, value: str) -> None:
27+
super().__setitem__(key.lower(), value)
28+
29+
def __getitem__(self, key: str) -> str:
30+
return super().__getitem__(key.lower())
31+
32+
def __contains__(self, key: object) -> bool:
33+
return super().__contains__(key.lower() if isinstance(key, str) else key)
34+
35+
def get(self, key: str, default: str | None = None) -> str | None: # type: ignore[override]
36+
return super().get(key.lower(), default)
37+
38+
def setdefault(self, key: str, default: str = '') -> str: # type: ignore[override]
39+
return super().setdefault(key.lower(), default)
40+
41+
42+
class MockHTTPResponse:
43+
"""Library-agnostic mock HTTP response implementing HTTPResponseProtocol."""
44+
45+
def __init__(
46+
self,
47+
content: str | bytes = '',
48+
status_code: int = 200,
49+
headers: dict[str, str] | None = None,
50+
json_data: dict[str, Any] | None = None,
51+
file_path: str | None = None,
52+
cookies: dict[str, str] | None = None,
53+
elapsed_seconds: float = 0.1,
54+
normalize_content: bool = True,
55+
):
56+
if json_data is not None:
57+
content = json.dumps(json_data)
58+
# Copy to avoid mutating the caller's dict
59+
headers = dict(headers) if headers is not None else {}
60+
headers.setdefault('Content-Type', 'application/json')
61+
elif file_path is not None:
62+
# Open in binary mode to handle both text and binary files correctly
63+
# This prevents encoding errors and platform-specific newline translation
64+
with open(file_path, 'rb') as f:
65+
content = f.read()
66+
67+
if normalize_content and (
68+
(isinstance(content, str) and content.startswith('\n'))
69+
or (isinstance(content, bytes) and content.startswith(b'\n'))
70+
):
71+
content = content[1:]
72+
73+
self._content = content.encode('utf-8') if isinstance(content, str) else content
74+
self.status_code = status_code
75+
self.headers = _CaseInsensitiveDict(headers or {})
76+
self.cookies = cookies or {}
77+
self.encoding: str | None = None
78+
79+
from datetime import timedelta
80+
81+
self.elapsed = timedelta(seconds=elapsed_seconds)
82+
self._stream = BytesIO(self._content)
83+
84+
def mock_getpeercert(_self, binary_form=False):
85+
return b'mock-cert' if binary_form else {}
86+
87+
self.raw = type(
88+
'MockRaw',
89+
(),
90+
{
91+
'connection': type(
92+
'MockConnection', (), {'sock': type('MockSocket', (), {'getpeercert': mock_getpeercert})()}
93+
)()
94+
},
95+
)()
96+
97+
@property
98+
def content(self) -> bytes:
99+
return self._content
100+
101+
@property
102+
def text(self) -> str:
103+
return self._content.decode('utf-8')
104+
105+
def json(self, **kwargs: Any) -> Any:
106+
return json.loads(self.text, **kwargs)
107+
108+
def raise_for_status(self) -> None:
109+
if self.status_code >= 400:
110+
from datadog_checks.base.utils.http_exceptions import HTTPStatusError
111+
112+
message = (
113+
f'{self.status_code} Client Error' if self.status_code < 500 else f'{self.status_code} Server Error'
114+
)
115+
raise HTTPStatusError(message, response=self)
116+
117+
def iter_content(self, chunk_size: int | None = None, decode_unicode: bool = False) -> Iterator[bytes | str]:
118+
# chunk_size=None means return the entire content as a single chunk (matches requests behavior)
119+
chunk_size = chunk_size if chunk_size is not None else len(self._content) or 1
120+
self._stream.seek(0)
121+
while chunk := self._stream.read(chunk_size):
122+
# Decode to string when decode_unicode=True (matches requests behavior)
123+
yield chunk.decode('utf-8') if decode_unicode else chunk
124+
125+
def iter_lines(
126+
self, chunk_size: int | None = None, decode_unicode: bool = False, delimiter: bytes | str | None = None
127+
) -> Iterator[bytes | str]:
128+
# Handle string delimiter by converting to bytes
129+
if isinstance(delimiter, str):
130+
delimiter = delimiter.encode('utf-8')
131+
delimiter = delimiter or b'\n'
132+
133+
self._stream.seek(0)
134+
lines = self._stream.read().split(delimiter)
135+
# bytes.split() produces a trailing empty element when content ends with the
136+
# delimiter (e.g. b'a\nb\n'.split(b'\n') == [b'a', b'b', b'']). requests uses
137+
# splitlines() for the default case which does not have this behavior, so we
138+
# strip the trailing empty element to match.
139+
if lines and not lines[-1]:
140+
lines.pop()
141+
for line in lines:
142+
# Decode to string when decode_unicode=True (matches requests behavior)
143+
yield line.decode('utf-8') if decode_unicode else line
144+
145+
def close(self) -> None:
146+
# No-op: requests.Response.close() releases the network connection, but
147+
# content is already buffered in memory. Matching that behaviour here
148+
# so the same instance can be returned by a mock multiple times.
149+
pass
150+
151+
def __enter__(self) -> 'MockHTTPResponse':
152+
return self
153+
154+
def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> bool | None:
155+
return None

datadog_checks_base/tests/base/checks/openmetrics/test_legacy/test_openmetrics.py

Lines changed: 33 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,9 @@
1919
from prometheus_client.samples import Sample
2020

2121
from datadog_checks.base import ensure_bytes
22+
from datadog_checks.base.utils.http_testing import MockHTTPResponse
2223
from datadog_checks.checks.openmetrics import OpenMetricsBaseCheck
2324
from datadog_checks.dev import get_here
24-
from datadog_checks.dev.http import MockResponse
2525

2626
text_content_type = 'text/plain; version=0.0.4'
2727
FIXTURE_PATH = os.path.abspath(os.path.join(get_here(), '..', '..', '..', '..', 'fixtures', 'prometheus'))
@@ -113,7 +113,7 @@ def test_config_instance(mocked_prometheus_check):
113113

114114
def test_process(text_data, mocked_prometheus_check, mocked_prometheus_scraper_config, ref_gauge):
115115
check = mocked_prometheus_check
116-
check.poll = mock.MagicMock(return_value=MockResponse(text_data, headers={'Content-Type': text_content_type}))
116+
check.poll = mock.MagicMock(return_value=MockHTTPResponse(text_data, headers={'Content-Type': text_content_type}))
117117
check.process_metric = mock.MagicMock()
118118
check.process(mocked_prometheus_scraper_config)
119119
check.poll.assert_called_with(mocked_prometheus_scraper_config)
@@ -734,7 +734,7 @@ def test_filter_sample_on_gauge(p_check, mocked_prometheus_scraper_config):
734734
expected_metric.add_metric(['heapster-v1.4.3'], 1)
735735

736736
# Iter on the generator to get all metrics
737-
response = MockResponse(text_data, headers={'Content-Type': text_content_type})
737+
response = MockHTTPResponse(text_data, headers={'Content-Type': text_content_type})
738738
check = p_check
739739
mocked_prometheus_scraper_config['_text_filter_blacklist'] = ["deployment=\"kube-dns\""]
740740
metrics = list(check.parse_metric_family(response, mocked_prometheus_scraper_config))
@@ -767,7 +767,7 @@ def test_parse_one_gauge(p_check, mocked_prometheus_scraper_config):
767767
expected_etcd_metric.add_metric([], 1)
768768

769769
# Iter on the generator to get all metrics
770-
response = MockResponse(text_data, headers={'Content-Type': text_content_type})
770+
response = MockHTTPResponse(text_data, headers={'Content-Type': text_content_type})
771771
check = p_check
772772
metrics = list(check.parse_metric_family(response, mocked_prometheus_scraper_config))
773773

@@ -799,7 +799,7 @@ def test_parse_one_counter(p_check, mocked_prometheus_scraper_config):
799799
expected_etcd_metric.name = 'go_memstats_mallocs_total'
800800

801801
# Iter on the generator to get all metrics
802-
response = MockResponse(text_data, headers={'Content-Type': text_content_type})
802+
response = MockHTTPResponse(text_data, headers={'Content-Type': text_content_type})
803803
check = p_check
804804
metrics = list(check.parse_metric_family(response, mocked_prometheus_scraper_config))
805805

@@ -857,7 +857,7 @@ def test_parse_one_histograms_with_label(p_check, mocked_prometheus_scraper_conf
857857
)
858858

859859
# Iter on the generator to get all metrics
860-
response = MockResponse(text_data, headers={'Content-Type': text_content_type})
860+
response = MockHTTPResponse(text_data, headers={'Content-Type': text_content_type})
861861
check = p_check
862862
metrics = list(check.parse_metric_family(response, mocked_prometheus_scraper_config))
863863

@@ -991,7 +991,7 @@ def test_parse_one_histogram(p_check, mocked_prometheus_scraper_config):
991991
)
992992

993993
# Iter on the generator to get all metrics
994-
response = MockResponse(text_data, headers={'Content-Type': text_content_type})
994+
response = MockHTTPResponse(text_data, headers={'Content-Type': text_content_type})
995995
check = p_check
996996
metrics = list(check.parse_metric_family(response, mocked_prometheus_scraper_config))
997997
assert 1 == len(metrics)
@@ -1093,7 +1093,7 @@ def test_parse_two_histograms_with_label(p_check, mocked_prometheus_scraper_conf
10931093
)
10941094

10951095
# Iter on the generator to get all metrics
1096-
response = MockResponse(text_data, headers={'Content-Type': text_content_type})
1096+
response = MockHTTPResponse(text_data, headers={'Content-Type': text_content_type})
10971097
check = p_check
10981098
metrics = list(check.parse_metric_family(response, mocked_prometheus_scraper_config))
10991099

@@ -1131,7 +1131,7 @@ def test_decumulate_histogram_buckets(p_check, mocked_prometheus_scraper_config)
11311131
'rest_client_request_latency_seconds_count{url="http://127.0.0.1:8080/api",verb="GET"} 755\n'
11321132
)
11331133

1134-
response = MockResponse(text_data, headers={'Content-Type': text_content_type})
1134+
response = MockHTTPResponse(text_data, headers={'Content-Type': text_content_type})
11351135
check = p_check
11361136
metrics = list(check.parse_metric_family(response, mocked_prometheus_scraper_config))
11371137

@@ -1220,7 +1220,7 @@ def test_decumulate_histogram_buckets_single_bucket(p_check, mocked_prometheus_s
12201220
'rest_client_request_latency_seconds_count{url="http://127.0.0.1:8080/api",verb="GET"} 755\n'
12211221
)
12221222

1223-
response = MockResponse(text_data, headers={'Content-Type': text_content_type})
1223+
response = MockHTTPResponse(text_data, headers={'Content-Type': text_content_type})
12241224
check = p_check
12251225
metrics = list(check.parse_metric_family(response, mocked_prometheus_scraper_config))
12261226

@@ -1283,7 +1283,7 @@ def test_decumulate_histogram_buckets_multiple_contexts(p_check, mocked_promethe
12831283
'rest_client_request_latency_seconds_count{url="http://127.0.0.1:8080/api",verb="POST"} 150\n'
12841284
)
12851285

1286-
response = MockResponse(text_data, headers={'Content-Type': text_content_type})
1286+
response = MockHTTPResponse(text_data, headers={'Content-Type': text_content_type})
12871287
check = p_check
12881288
metrics = list(check.parse_metric_family(response, mocked_prometheus_scraper_config))
12891289

@@ -1351,7 +1351,7 @@ def test_decumulate_histogram_buckets_negative_buckets(p_check, mocked_prometheu
13511351
'random_histogram_count{url="http://127.0.0.1:8080/api",verb="GET"} 70\n'
13521352
)
13531353

1354-
response = MockResponse(text_data, headers={'Content-Type': text_content_type})
1354+
response = MockHTTPResponse(text_data, headers={'Content-Type': text_content_type})
13551355
check = p_check
13561356
metrics = list(check.parse_metric_family(response, mocked_prometheus_scraper_config))
13571357

@@ -1403,7 +1403,7 @@ def test_decumulate_histogram_buckets_no_buckets(p_check, mocked_prometheus_scra
14031403
'rest_client_request_latency_seconds_count{url="http://127.0.0.1:8080/api",verb="GET"} 755\n'
14041404
)
14051405

1406-
response = MockResponse(text_data, headers={'Content-Type': text_content_type})
1406+
response = MockHTTPResponse(text_data, headers={'Content-Type': text_content_type})
14071407
check = p_check
14081408
metrics = list(check.parse_metric_family(response, mocked_prometheus_scraper_config))
14091409

@@ -1474,7 +1474,7 @@ def test_parse_one_summary(p_check, mocked_prometheus_scraper_config):
14741474
expected_etcd_metric.add_sample("http_response_size_bytes", {"handler": "prometheus", "quantile": "0.99"}, 25763.0)
14751475

14761476
# Iter on the generator to get all metrics
1477-
response = MockResponse(text_data, headers={'Content-Type': text_content_type})
1477+
response = MockHTTPResponse(text_data, headers={'Content-Type': text_content_type})
14781478
check = p_check
14791479
metrics = list(check.parse_metric_family(response, mocked_prometheus_scraper_config))
14801480

@@ -1517,7 +1517,7 @@ def test_parse_one_summary_with_no_quantile(p_check, mocked_prometheus_scraper_c
15171517
expected_etcd_metric.add_metric(["prometheus"], 5.0, 120512.0)
15181518

15191519
# Iter on the generator to get all metrics
1520-
response = MockResponse(text_data, headers={'Content-Type': text_content_type})
1520+
response = MockHTTPResponse(text_data, headers={'Content-Type': text_content_type})
15211521
check = p_check
15221522
metrics = list(check.parse_metric_family(response, mocked_prometheus_scraper_config))
15231523

@@ -1572,7 +1572,7 @@ def test_parse_two_summaries_with_labels(p_check, mocked_prometheus_scraper_conf
15721572
)
15731573

15741574
# Iter on the generator to get all metrics
1575-
response = MockResponse(text_data, headers={'Content-Type': text_content_type})
1575+
response = MockHTTPResponse(text_data, headers={'Content-Type': text_content_type})
15761576
check = p_check
15771577
metrics = list(check.parse_metric_family(response, mocked_prometheus_scraper_config))
15781578

@@ -1613,7 +1613,7 @@ def test_parse_one_summary_with_none_values(p_check, mocked_prometheus_scraper_c
16131613
)
16141614

16151615
# Iter on the generator to get all metrics
1616-
response = MockResponse(text_data, headers={'Content-Type': text_content_type})
1616+
response = MockHTTPResponse(text_data, headers={'Content-Type': text_content_type})
16171617
check = p_check
16181618
metrics = list(check.parse_metric_family(response, mocked_prometheus_scraper_config))
16191619
assert 1 == len(metrics)
@@ -2634,7 +2634,7 @@ def test_filter_metrics(
26342634
def test_metadata_default(mocked_openmetrics_check_factory, text_data, datadog_agent):
26352635
instance = dict(OPENMETRICS_CHECK_INSTANCE)
26362636
check = mocked_openmetrics_check_factory(instance)
2637-
check.poll = mock.MagicMock(return_value=MockResponse(text_data, headers={'Content-Type': text_content_type}))
2637+
check.poll = mock.MagicMock(return_value=MockHTTPResponse(text_data, headers={'Content-Type': text_content_type}))
26382638

26392639
check.check(instance)
26402640
datadog_agent.assert_metadata_count(0)
@@ -2645,7 +2645,7 @@ def test_metadata_transformer(mocked_openmetrics_check_factory, text_data, datad
26452645
instance['metadata_metric_name'] = 'kubernetes_build_info'
26462646
instance['metadata_label_map'] = {'version': 'gitVersion'}
26472647
check = mocked_openmetrics_check_factory(instance)
2648-
check.poll = mock.MagicMock(return_value=MockResponse(text_data, headers={'Content-Type': text_content_type}))
2648+
check.poll = mock.MagicMock(return_value=MockHTTPResponse(text_data, headers={'Content-Type': text_content_type}))
26492649

26502650
version_metadata = {
26512651
'version.major': '1',
@@ -2672,7 +2672,10 @@ def test_ssl_verify_not_raise_warning(caplog, mocked_openmetrics_check_factory,
26722672
check = mocked_openmetrics_check_factory(instance)
26732673
scraper_config = check.get_scraper_config(instance)
26742674

2675-
with caplog.at_level(logging.DEBUG), mock.patch('requests.Session.get', return_value=MockResponse('httpbin.org')):
2675+
with (
2676+
caplog.at_level(logging.DEBUG),
2677+
mock.patch('requests.Session.get', return_value=MockHTTPResponse('httpbin.org')),
2678+
):
26762679
resp = check.send_request('https://httpbin.org/get', scraper_config)
26772680

26782681
assert "httpbin.org" in resp.content.decode('utf-8')
@@ -2696,7 +2699,10 @@ def test_send_request_with_dynamic_prometheus_url(caplog, mocked_openmetrics_che
26962699
# `prometheus_url` changed just before calling `send_request`
26972700
scraper_config['prometheus_url'] = 'https://www.example.com/foo/bar'
26982701

2699-
with caplog.at_level(logging.DEBUG), mock.patch('requests.Session.get', return_value=MockResponse('httpbin.org')):
2702+
with (
2703+
caplog.at_level(logging.DEBUG),
2704+
mock.patch('requests.Session.get', return_value=MockHTTPResponse('httpbin.org')),
2705+
):
27002706
resp = check.send_request('https://httpbin.org/get', scraper_config)
27012707

27022708
assert "httpbin.org" in resp.content.decode('utf-8')
@@ -2736,7 +2742,7 @@ def test_simple_type_overrides(aggregator, mocked_prometheus_check, text_data):
27362742
config = check.get_scraper_config(instance)
27372743
config['_dry_run'] = False
27382744

2739-
check.poll = mock.MagicMock(return_value=MockResponse(text_data, headers={'Content-Type': text_content_type}))
2745+
check.poll = mock.MagicMock(return_value=MockHTTPResponse(text_data, headers={'Content-Type': text_content_type}))
27402746
check.process(config)
27412747

27422748
aggregator.assert_metric('prometheus.process.vm.bytes', count=1, metric_type=aggregator.MONOTONIC_COUNT)
@@ -2759,7 +2765,7 @@ def test_wildcard_type_overrides(aggregator, mocked_prometheus_check, text_data)
27592765
config = check.get_scraper_config(instance)
27602766
config['_dry_run'] = False
27612767

2762-
check.poll = mock.MagicMock(return_value=MockResponse(text_data, headers={'Content-Type': text_content_type}))
2768+
check.poll = mock.MagicMock(return_value=MockHTTPResponse(text_data, headers={'Content-Type': text_content_type}))
27632769
check.process(config)
27642770

27652771
aggregator.assert_metric('prometheus.process.vm.bytes', count=1, metric_type=aggregator.MONOTONIC_COUNT)
@@ -2942,7 +2948,7 @@ def test_use_process_start_time(
29422948

29432949
check = mocked_openmetrics_check_factory(instance)
29442950
test_data = _make_test_use_process_start_time_data(process_start_time)
2945-
check.poll = mock.MagicMock(return_value=MockResponse(test_data, headers={'Content-Type': text_content_type}))
2951+
check.poll = mock.MagicMock(return_value=MockHTTPResponse(test_data, headers={'Content-Type': text_content_type}))
29462952

29472953
for _ in range(0, 5):
29482954
aggregator.reset()
@@ -2995,7 +3001,9 @@ def test_refresh_bearer_token(text_data, mocked_openmetrics_check_factory):
29953001

29963002
with patch.object(OpenMetricsBaseCheck, 'KUBERNETES_TOKEN_PATH', os.path.join(TOKENS_PATH, 'default_token')):
29973003
check = mocked_openmetrics_check_factory(instance)
2998-
check.poll = mock.MagicMock(return_value=MockResponse(text_data, headers={'Content-Type': text_content_type}))
3004+
check.poll = mock.MagicMock(
3005+
return_value=MockHTTPResponse(text_data, headers={'Content-Type': text_content_type})
3006+
)
29993007
instance = check.get_scraper_config(instance)
30003008
assert instance['_bearer_token'] == 'my default token'
30013009
time.sleep(1.5)

0 commit comments

Comments
 (0)