Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions datadog_checks_base/changelog.d/22704.added.1
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Forward instance config (auth, TLS, timeout, proxy, headers) to HTTPXWrapper.
9 changes: 6 additions & 3 deletions datadog_checks_base/datadog_checks/base/checks/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -387,11 +387,14 @@ def http(self) -> HTTPClientProtocol:
if not hasattr(self, '_http'):
# See Performance Optimizations in this package's README.md.
if is_affirmative((self.instance or {}).get('use_httpx', False)):
import httpx

from datadog_checks.base.utils.http_httpx import HTTPXWrapper

self._http = HTTPXWrapper(httpx.Client())
self._http = HTTPXWrapper(
self.instance or {},
self.init_config,
self.HTTP_CONFIG_REMAPPER,
self.log,
)
else:
from datadog_checks.base.utils.http import RequestsWrapper

Expand Down
189 changes: 187 additions & 2 deletions datadog_checks_base/datadog_checks/base/utils/http_httpx.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,14 @@
# Licensed under a 3-clause BSD style license (see LICENSE)
from __future__ import annotations

import logging
from typing import Any, Iterator

import httpx

from datadog_checks.base.config import is_affirmative
from datadog_checks.base.utils.headers import get_default_headers, update_headers

from .http_exceptions import (
HTTPConnectionError,
HTTPError,
Expand All @@ -15,6 +19,181 @@
HTTPTimeoutError,
)

LOGGER = logging.getLogger(__name__)

DEFAULT_TIMEOUT = 10

# Fields recognized from instance/init_config for httpx client construction
_STANDARD_FIELDS = {
'allow_redirects': True,
'auth_type': 'basic',
'connect_timeout': None,
'extra_headers': None,
'headers': None,
'kerberos_auth': None,
'kerberos_delegate': False,
'kerberos_force_initiate': False,
'kerberos_hostname': None,
'kerberos_keytab': None,
'kerberos_principal': None,
'ntlm_domain': None,
'password': None,
'proxy': None,
'read_timeout': None,
'skip_proxy': False,
'timeout': DEFAULT_TIMEOUT,
'tls_ca_cert': None,
'tls_cert': None,
'tls_private_key': None,
'tls_verify': True,
'username': None,
}

# Legacy field aliases applied before reading standard fields
_DEFAULT_REMAPPED_FIELDS = {
'kerberos': {'name': 'kerberos_auth'},
'no_proxy': {'name': 'skip_proxy'},
}


def _build_httpx_client(
instance: dict,
init_config: dict,
remapper: dict | None = None,
logger: logging.Logger | None = None,
) -> httpx.Client:
log = logger or LOGGER

# Merge default fields; init_config provides global overrides
default_fields = dict(_STANDARD_FIELDS)
default_fields['skip_proxy'] = init_config.get('skip_proxy', default_fields['skip_proxy'])
default_fields['timeout'] = init_config.get('timeout', default_fields['timeout'])

# Populate config from instance, using defaults for missing fields
config = {field: instance.get(field, value) for field, value in default_fields.items()}

# Apply remapper: normalize legacy/integration-specific field names
if remapper is None:
remapper = {}
remapper.update(_DEFAULT_REMAPPED_FIELDS)

for remapped_field, data in remapper.items():
field = data.get('name')
if field not in _STANDARD_FIELDS:
continue
# Standard field already explicitly set — skip remapped alias
if field in instance:
continue
default = default_fields[field]
if data.get('invert'):
default = not default
value = instance.get(remapped_field, data.get('default', default))
if data.get('invert'):
value = not is_affirmative(value)
config[field] = value

# --- Timeouts ---
connect_timeout = read_timeout = float(config['timeout'])
if config['connect_timeout'] is not None:
connect_timeout = float(config['connect_timeout'])
if config['read_timeout'] is not None:
read_timeout = float(config['read_timeout'])
# read_timeout is the default; connect overrides the connect-phase only
timeout = httpx.Timeout(read_timeout, connect=connect_timeout)

# --- Headers ---
headers = get_default_headers()
if config['headers']:
headers.clear()
update_headers(headers, config['headers'])
if config['extra_headers']:
update_headers(headers, config['extra_headers'])

# --- Auth ---
auth_type = (config['auth_type'] or 'basic').lower()
if auth_type == 'basic':
if config['kerberos_auth']:
log.warning(
'The ability to use Kerberos auth without explicitly setting auth_type to '
'`kerberos` is deprecated and will be removed in Agent 8'
)
auth_type = 'kerberos'
elif config['ntlm_domain']:
log.warning(
'The ability to use NTLM auth without explicitly setting auth_type to '
'`ntlm` is deprecated and will be removed in Agent 8'
)
auth_type = 'ntlm'

auth: httpx.Auth | None = None
if auth_type == 'basic':
if config['username'] is not None:
auth = httpx.BasicAuth(config['username'], config['password'] or '')
Comment on lines +130 to +131
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Require both username and password for basic auth

This enables BasicAuth whenever username is set and silently substitutes an empty string for a missing password. RequestsWrapper only enables basic auth when both username and password are provided, so on the HTTPX path a partially configured instance now sends an Authorization header that was previously omitted, which can cause unexpected authentication failures (for example, 401s) when use_httpx is turned on.

Useful? React with 👍 / 👎.

elif auth_type == 'digest':
if config['username'] is not None:
auth = httpx.DigestAuth(config['username'], config['password'] or '')
elif auth_type == 'kerberos':
from datadog_checks.base.utils.httpx_auth import KerberosAuth
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Ship httpx_auth before enabling Kerberos/NTLM in HTTPXWrapper

When use_httpx: true is combined with auth_type: kerberos or auth_type: ntlm, this code imports datadog_checks.base.utils.httpx_auth, but that module is not present in this repository tree (datadog_checks_base/datadog_checks/base/utils/ has no httpx_auth module). That means client construction fails with ModuleNotFoundError at runtime for those auth modes, so checks cannot start with those configurations.

Useful? React with 👍 / 👎.


auth = KerberosAuth(
mutual_authentication=config.get('kerberos_auth') or 'required',
delegate=is_affirmative(config['kerberos_delegate']),
force_preemptive=is_affirmative(config['kerberos_force_initiate']),
hostname_override=config['kerberos_hostname'],
principal=config['kerberos_principal'],
keytab=config['kerberos_keytab'],
)
elif auth_type == 'ntlm':
from datadog_checks.base.utils.httpx_auth import NTLMAuth

auth = NTLMAuth(config['ntlm_domain'], config['password'])

# --- TLS / verify ---
verify: bool | str = True
if isinstance(config['tls_ca_cert'], str):
verify = config['tls_ca_cert']
elif not is_affirmative(config['tls_verify']):
verify = False

cert: tuple[str, str] | str | None = None
if isinstance(config['tls_cert'], str):
if isinstance(config['tls_private_key'], str):
cert = (config['tls_cert'], config['tls_private_key'])
else:
cert = config['tls_cert']

# --- Proxies ---
# trust_env=True lets httpx fall back to HTTP_PROXY/HTTPS_PROXY env vars (same as requests)
trust_env = True
mounts: dict[str, httpx.BaseTransport | None] | None = None

if is_affirmative(config['skip_proxy']):
trust_env = False
else:
raw_proxy = config['proxy'] or init_config.get('proxy')
if raw_proxy:
mounts = {}
for scheme, url in raw_proxy.items():
# 'no_proxy' entries are not proxy URLs — skip them
if scheme == 'no_proxy' or not url:
continue
# Convert requests format {'http': url} to httpx mount format {'http://': transport}
key = scheme if scheme.endswith('://') else f'{scheme}://'
mounts[key] = httpx.HTTPTransport(proxy=url)
if not mounts:
mounts = None

return httpx.Client(
auth=auth,
verify=verify,
cert=cert,
timeout=timeout,
headers=headers,
follow_redirects=is_affirmative(config['allow_redirects']),
mounts=mounts,
trust_env=trust_env,
)


def _translate_httpx_error(e: httpx.HTTPError) -> HTTPError:
if isinstance(e, httpx.HTTPStatusError):
Expand Down Expand Up @@ -65,8 +244,14 @@ def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:


class HTTPXWrapper:
def __init__(self, client: httpx.Client) -> None:
self._client = client
def __init__(
self,
instance: dict,
init_config: dict,
remapper: dict | None = None,
logger: logging.Logger | None = None,
) -> None:
self._client = _build_httpx_client(instance, init_config, remapper, logger)

def __del__(self) -> None: # no cov
try:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,11 @@ def http_client(request):
with patch.object(requests.Session, "get", return_value=_requests_response(_BODY)):
yield RequestsWrapper({}, {})
else:
yield HTTPXWrapper(httpx.Client(transport=_httpx_transport(_BODY)))
with patch(
'datadog_checks.base.utils.http_httpx._build_httpx_client',
return_value=httpx.Client(transport=_httpx_transport(_BODY)),
):
yield HTTPXWrapper({}, {})


def test_status_code(http_client):
Expand Down
Loading
Loading