|
3 | 3 | # Licensed under a 3-clause BSD style license (see LICENSE) |
4 | 4 | from __future__ import annotations |
5 | 5 |
|
| 6 | +import logging |
6 | 7 | from typing import Any, Iterator |
7 | 8 |
|
8 | 9 | import httpx |
9 | 10 |
|
| 11 | +from datadog_checks.base.config import is_affirmative |
| 12 | +from datadog_checks.base.utils.headers import get_default_headers, update_headers |
| 13 | + |
10 | 14 | from .http_exceptions import ( |
11 | 15 | HTTPConnectionError, |
12 | 16 | HTTPError, |
|
15 | 19 | HTTPTimeoutError, |
16 | 20 | ) |
17 | 21 |
|
| 22 | +LOGGER = logging.getLogger(__name__) |
| 23 | + |
| 24 | +DEFAULT_TIMEOUT = 10 |
| 25 | + |
| 26 | +# Fields recognized from instance/init_config for httpx client construction |
| 27 | +_STANDARD_FIELDS = { |
| 28 | + 'allow_redirects': True, |
| 29 | + 'auth_type': 'basic', |
| 30 | + 'connect_timeout': None, |
| 31 | + 'extra_headers': None, |
| 32 | + 'headers': None, |
| 33 | + 'kerberos_auth': None, |
| 34 | + 'kerberos_delegate': False, |
| 35 | + 'kerberos_force_initiate': False, |
| 36 | + 'kerberos_hostname': None, |
| 37 | + 'kerberos_keytab': None, |
| 38 | + 'kerberos_principal': None, |
| 39 | + 'ntlm_domain': None, |
| 40 | + 'password': None, |
| 41 | + 'proxy': None, |
| 42 | + 'read_timeout': None, |
| 43 | + 'skip_proxy': False, |
| 44 | + 'timeout': DEFAULT_TIMEOUT, |
| 45 | + 'tls_ca_cert': None, |
| 46 | + 'tls_cert': None, |
| 47 | + 'tls_private_key': None, |
| 48 | + 'tls_verify': True, |
| 49 | + 'username': None, |
| 50 | +} |
| 51 | + |
| 52 | +# Legacy field aliases applied before reading standard fields |
| 53 | +_DEFAULT_REMAPPED_FIELDS = { |
| 54 | + 'kerberos': {'name': 'kerberos_auth'}, |
| 55 | + 'no_proxy': {'name': 'skip_proxy'}, |
| 56 | +} |
| 57 | + |
| 58 | + |
| 59 | +def _build_httpx_client( |
| 60 | + instance: dict, |
| 61 | + init_config: dict, |
| 62 | + remapper: dict | None = None, |
| 63 | + logger: logging.Logger | None = None, |
| 64 | +) -> httpx.Client: |
| 65 | + log = logger or LOGGER |
| 66 | + |
| 67 | + # Merge default fields; init_config provides global overrides |
| 68 | + default_fields = dict(_STANDARD_FIELDS) |
| 69 | + default_fields['skip_proxy'] = init_config.get('skip_proxy', default_fields['skip_proxy']) |
| 70 | + default_fields['timeout'] = init_config.get('timeout', default_fields['timeout']) |
| 71 | + |
| 72 | + # Populate config from instance, using defaults for missing fields |
| 73 | + config = {field: instance.get(field, value) for field, value in default_fields.items()} |
| 74 | + |
| 75 | + # Apply remapper: normalize legacy/integration-specific field names |
| 76 | + if remapper is None: |
| 77 | + remapper = {} |
| 78 | + remapper.update(_DEFAULT_REMAPPED_FIELDS) |
| 79 | + |
| 80 | + for remapped_field, data in remapper.items(): |
| 81 | + field = data.get('name') |
| 82 | + if field not in _STANDARD_FIELDS: |
| 83 | + continue |
| 84 | + # Standard field already explicitly set — skip remapped alias |
| 85 | + if field in instance: |
| 86 | + continue |
| 87 | + default = default_fields[field] |
| 88 | + if data.get('invert'): |
| 89 | + default = not default |
| 90 | + value = instance.get(remapped_field, data.get('default', default)) |
| 91 | + if data.get('invert'): |
| 92 | + value = not is_affirmative(value) |
| 93 | + config[field] = value |
| 94 | + |
| 95 | + # --- Timeouts --- |
| 96 | + connect_timeout = read_timeout = float(config['timeout']) |
| 97 | + if config['connect_timeout'] is not None: |
| 98 | + connect_timeout = float(config['connect_timeout']) |
| 99 | + if config['read_timeout'] is not None: |
| 100 | + read_timeout = float(config['read_timeout']) |
| 101 | + # read_timeout is the default; connect overrides the connect-phase only |
| 102 | + timeout = httpx.Timeout(read_timeout, connect=connect_timeout) |
| 103 | + |
| 104 | + # --- Headers --- |
| 105 | + headers = get_default_headers() |
| 106 | + if config['headers']: |
| 107 | + headers.clear() |
| 108 | + update_headers(headers, config['headers']) |
| 109 | + if config['extra_headers']: |
| 110 | + update_headers(headers, config['extra_headers']) |
| 111 | + |
| 112 | + # --- Auth --- |
| 113 | + auth_type = (config['auth_type'] or 'basic').lower() |
| 114 | + if auth_type == 'basic': |
| 115 | + if config['kerberos_auth']: |
| 116 | + log.warning( |
| 117 | + 'The ability to use Kerberos auth without explicitly setting auth_type to ' |
| 118 | + '`kerberos` is deprecated and will be removed in Agent 8' |
| 119 | + ) |
| 120 | + auth_type = 'kerberos' |
| 121 | + elif config['ntlm_domain']: |
| 122 | + log.warning( |
| 123 | + 'The ability to use NTLM auth without explicitly setting auth_type to ' |
| 124 | + '`ntlm` is deprecated and will be removed in Agent 8' |
| 125 | + ) |
| 126 | + auth_type = 'ntlm' |
| 127 | + |
| 128 | + auth: httpx.Auth | None = None |
| 129 | + if auth_type == 'basic': |
| 130 | + if config['username'] is not None: |
| 131 | + auth = httpx.BasicAuth(config['username'], config['password'] or '') |
| 132 | + elif auth_type == 'digest': |
| 133 | + if config['username'] is not None: |
| 134 | + auth = httpx.DigestAuth(config['username'], config['password'] or '') |
| 135 | + elif auth_type == 'kerberos': |
| 136 | + from datadog_checks.base.utils.httpx_auth import KerberosAuth |
| 137 | + |
| 138 | + auth = KerberosAuth( |
| 139 | + mutual_authentication=config.get('kerberos_auth') or 'required', |
| 140 | + delegate=is_affirmative(config['kerberos_delegate']), |
| 141 | + force_preemptive=is_affirmative(config['kerberos_force_initiate']), |
| 142 | + hostname_override=config['kerberos_hostname'], |
| 143 | + principal=config['kerberos_principal'], |
| 144 | + keytab=config['kerberos_keytab'], |
| 145 | + ) |
| 146 | + elif auth_type == 'ntlm': |
| 147 | + from datadog_checks.base.utils.httpx_auth import NTLMAuth |
| 148 | + |
| 149 | + auth = NTLMAuth(config['ntlm_domain'], config['password']) |
| 150 | + |
| 151 | + # --- TLS / verify --- |
| 152 | + verify: bool | str = True |
| 153 | + if isinstance(config['tls_ca_cert'], str): |
| 154 | + verify = config['tls_ca_cert'] |
| 155 | + elif not is_affirmative(config['tls_verify']): |
| 156 | + verify = False |
| 157 | + |
| 158 | + cert: tuple[str, str] | str | None = None |
| 159 | + if isinstance(config['tls_cert'], str): |
| 160 | + if isinstance(config['tls_private_key'], str): |
| 161 | + cert = (config['tls_cert'], config['tls_private_key']) |
| 162 | + else: |
| 163 | + cert = config['tls_cert'] |
| 164 | + |
| 165 | + # --- Proxies --- |
| 166 | + # trust_env=True lets httpx fall back to HTTP_PROXY/HTTPS_PROXY env vars (same as requests) |
| 167 | + trust_env = True |
| 168 | + mounts: dict[str, httpx.BaseTransport | None] | None = None |
| 169 | + |
| 170 | + if is_affirmative(config['skip_proxy']): |
| 171 | + trust_env = False |
| 172 | + else: |
| 173 | + raw_proxy = config['proxy'] or init_config.get('proxy') |
| 174 | + if raw_proxy: |
| 175 | + mounts = {} |
| 176 | + for scheme, url in raw_proxy.items(): |
| 177 | + # 'no_proxy' entries are not proxy URLs — skip them |
| 178 | + if scheme == 'no_proxy' or not url: |
| 179 | + continue |
| 180 | + # Convert requests format {'http': url} to httpx mount format {'http://': transport} |
| 181 | + key = scheme if scheme.endswith('://') else f'{scheme}://' |
| 182 | + mounts[key] = httpx.HTTPTransport(proxy=url) |
| 183 | + if not mounts: |
| 184 | + mounts = None |
| 185 | + |
| 186 | + return httpx.Client( |
| 187 | + auth=auth, |
| 188 | + verify=verify, |
| 189 | + cert=cert, |
| 190 | + timeout=timeout, |
| 191 | + headers=headers, |
| 192 | + follow_redirects=is_affirmative(config['allow_redirects']), |
| 193 | + mounts=mounts, |
| 194 | + trust_env=trust_env, |
| 195 | + ) |
| 196 | + |
18 | 197 |
|
19 | 198 | def _translate_httpx_error(e: httpx.HTTPError) -> HTTPError: |
20 | 199 | if isinstance(e, httpx.HTTPStatusError): |
@@ -65,8 +244,14 @@ def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None: |
65 | 244 |
|
66 | 245 |
|
67 | 246 | class HTTPXWrapper: |
68 | | - def __init__(self, client: httpx.Client) -> None: |
69 | | - self._client = client |
| 247 | + def __init__( |
| 248 | + self, |
| 249 | + instance: dict, |
| 250 | + init_config: dict, |
| 251 | + remapper: dict | None = None, |
| 252 | + logger: logging.Logger | None = None, |
| 253 | + ) -> None: |
| 254 | + self._client = _build_httpx_client(instance, init_config, remapper, logger) |
70 | 255 |
|
71 | 256 | def __del__(self) -> None: # no cov |
72 | 257 | try: |
|
0 commit comments