Skip to content

Commit 09cda3c

Browse files
committed
Add DNS SRV record support to HTTP Operator
1 parent 2e0b22a commit 09cda3c

9 files changed

Lines changed: 484 additions & 8 deletions

File tree

providers/http/docs/connections/http.rst

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,12 +47,22 @@ Password (optional)
4747
Host (optional)
4848
Specify the entire url or the base of the url for the service.
4949

50+
If "Use DNS SRV Lookup" is enabled, specify the DNS SRV record name instead
51+
(e.g. ``_http._tcp.example.com``) - Note the actual host and port are resolved from DNS at
52+
request time and any value set in the Port field is ignored.
53+
5054
Port (optional)
51-
Specify a port number if applicable.
55+
Specify a port number if applicable. Ignored when SRV lookup is enabled.
5256

5357
Schema (optional)
5458
Specify the service type etc: http/https.
5559

60+
Use DNS SRV Lookup (optional)
61+
Treat the Host field as a DNS SRV record name and resolve the target host/port at request time.
62+
63+
SRV Cache TTL (seconds) (optional)
64+
Specify the time to cache a resolved SRV target before re-resolving. (default 60 seconds)
65+
5666
Extra (optional)
5767
Specify headers and default requests parameters in json format.
5868
Following default requests parameters are taken into account:
@@ -64,6 +74,10 @@ Extra (optional)
6474
* ``allow_redirects``
6575
* ``max_redirects``
6676

77+
"Use DNS SRV Lookup" and "SRV Cache TTL" above are stored as the ``srv_lookup`` and
78+
``srv_cache_ttl`` keys in this same Extra field, so they can also be set directly in json
79+
here, e.g. when configuring the connection via an environment variable.
80+
6781

6882
When specifying the connection in environment variable you should specify
6983
it using URI syntax.
@@ -75,3 +89,10 @@ For example:
7589
.. code-block:: bash
7690
7791
export AIRFLOW_CONN_HTTP_DEFAULT='http://username:password@service.com:80/https?headers=header'
92+
93+
To enable SRV lookup via an environment variable, set ``srv_lookup`` in the Extra query
94+
parameter:
95+
96+
.. code-block:: bash
97+
98+
export AIRFLOW_CONN_HTTP_DEFAULT='https://_http._tcp.example.com/https?srv_lookup=true'

providers/http/docs/index.rst

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,23 @@ PIP package Version required
111111
``pydantic`` ``>=2.11.0``
112112
========================================== ======================================
113113

114+
Optional dependencies
115+
---------------------
116+
117+
These extras install optional third-party libraries that enable additional features of the provider.
118+
Install them when installing from PyPI. For example:
119+
120+
.. code-block:: bash
121+
122+
pip install apache-airflow-providers-http[srv]
123+
124+
125+
======= ====================
126+
Extra Dependencies
127+
======= ====================
128+
``srv`` ``dnspython>=2.0.0``
129+
======= ====================
130+
114131
Downloading official packages
115132
-----------------------------
116133

providers/http/provider.yaml

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -126,4 +126,23 @@ connection-types:
126126
hidden-fields: []
127127
relabeling: {}
128128
placeholders: {}
129-
conn-fields: {}
129+
conn-fields:
130+
srv_lookup:
131+
label: Use DNS SRV Lookup
132+
description: >-
133+
Whether to treat the Host field as a DNS SRV record name and resolve the target
134+
host/port at request time.
135+
schema:
136+
type:
137+
- boolean
138+
- "null"
139+
default: false
140+
srv_cache_ttl:
141+
label: SRV Cache TTL (seconds)
142+
description: Time to cache a resolved SRV target before re-resolving.
143+
schema:
144+
type:
145+
- number
146+
- "null"
147+
minimum: 0
148+
default: 60

providers/http/pyproject.toml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,13 @@ dependencies = [
7171
"pydantic>=2.11.0",
7272
]
7373

74+
# The optional dependencies should be modified in place in the generated file
75+
# Any change in the dependencies is preserved when the file is regenerated
76+
[project.optional-dependencies]
77+
"srv" = [
78+
"dnspython>=2.0.0",
79+
]
80+
7481
[dependency-groups]
7582
dev = [
7683
"apache-airflow",

providers/http/src/airflow/providers/http/exceptions.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,3 +25,7 @@ class HttpErrorException(AirflowException):
2525

2626
class HttpMethodException(AirflowException):
2727
"""Exception raised for invalid HTTP methods in Http hook."""
28+
29+
30+
class HttpSrvLookupException(AirflowException):
31+
"""Exception raised when DNS SRV record resolution fails or is misconfigured in Http hook."""

providers/http/src/airflow/providers/http/get_provider_info.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,18 @@ def get_provider_info():
6666
"hook-name": "HTTP",
6767
"connection-type": "http",
6868
"ui-field-behaviour": {"hidden-fields": [], "relabeling": {}, "placeholders": {}},
69-
"conn-fields": {},
69+
"conn-fields": {
70+
"srv_lookup": {
71+
"label": "Use DNS SRV Lookup",
72+
"description": "Whether to treat the Host field as a DNS SRV record name and resolve the target host/port at request time.",
73+
"schema": {"type": ["boolean", "null"], "default": False},
74+
},
75+
"srv_cache_ttl": {
76+
"label": "SRV Cache TTL (seconds)",
77+
"description": "Time to cache a resolved SRV target before re-resolving.",
78+
"schema": {"type": ["number", "null"], "minimum": 0, "default": 60},
79+
},
80+
},
7081
}
7182
],
7283
}

providers/http/src/airflow/providers/http/hooks/http.py

Lines changed: 145 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,11 @@
1717
# under the License.
1818
from __future__ import annotations
1919

20+
import asyncio
2021
import copy
21-
from collections.abc import AsyncGenerator, Awaitable, Callable
22+
import random
23+
import time
24+
from collections.abc import AsyncGenerator, Awaitable, Callable, Iterable
2225
from contextlib import asynccontextmanager
2326
from typing import TYPE_CHECKING, Any, cast
2427
from urllib.parse import urlparse
@@ -35,8 +38,9 @@
3538
from tenacity import retry_if_exception
3639

3740
from airflow.providers.common.compat.sdk import AirflowException, BaseHook
38-
from airflow.providers.http.exceptions import HttpErrorException, HttpMethodException
41+
from airflow.providers.http.exceptions import HttpErrorException, HttpMethodException, HttpSrvLookupException
3942
from airflow.utils.log.logging_mixin import LoggingMixin
43+
from airflow.utils.strings import to_boolean
4044

4145
if TYPE_CHECKING:
4246
from aiohttp.client_reqrep import ClientResponse
@@ -52,6 +56,19 @@ def _url_from_endpoint(base_url: str | None, endpoint: str | None) -> str:
5256
return (base_url or "") + (endpoint or "")
5357

5458

59+
def _select_srv_target(answers: Iterable[Any]) -> tuple[str, int]:
60+
"""Select a target host and port from resolved DNS SRV records."""
61+
candidates_by_priority: dict[int, list[Any]] = {}
62+
for record in answers:
63+
candidates_by_priority.setdefault(record.priority, []).append(record)
64+
# Failover mechanism: RFC 2782
65+
candidates = candidates_by_priority[min(candidates_by_priority)]
66+
chosen = random.choice(candidates)
67+
68+
target_host = str(chosen.target).rstrip(".")
69+
return target_host, chosen.port
70+
71+
5572
def _process_extra_options_from_connection(
5673
conn, extra_options: dict[str, Any]
5774
) -> tuple[dict[str, Any], dict[str, Any]]:
@@ -76,6 +93,9 @@ def _process_extra_options_from_connection(
7693
trust_env = conn_extra_options.pop("trust_env", None)
7794
check_response = conn_extra_options.pop("check_response", None)
7895

96+
conn_extra_options.pop("srv_lookup", None)
97+
conn_extra_options.pop("srv_cache_ttl", None)
98+
7999
if stream is not None and "stream" not in passed_extra_options:
80100
passed_extra_options["stream"] = stream
81101
if cert is not None and "cert" not in passed_extra_options:
@@ -135,6 +155,11 @@ class HttpHook(BaseHook):
135155
:param tcp_keep_alive_count: The TCP Keep Alive count parameter (corresponds to ``socket.TCP_KEEPCNT``)
136156
:param tcp_keep_alive_interval: The TCP Keep Alive interval parameter (corresponds to
137157
``socket.TCP_KEEPINTVL``)
158+
159+
Extra also supports resolving ``host`` via a DNS SRV record:
160+
161+
* ``srv_lookup`` (bool): treat ``host`` as an SRV record name, e.g. ``_http._tcp.example.com``.
162+
* ``srv_cache_ttl`` (float): SRV cache TTL in seconds (default 60).
138163
"""
139164

140165
conn_name_attr = "http_conn_id"
@@ -162,6 +187,12 @@ def __init__(
162187
self._base_url_initialized: bool = False
163188
self._retry_obj: Callable[..., Any]
164189
self._auth_type: Any = auth_type
190+
self._srv_lookup_enabled: bool = False
191+
self._srv_name: str | None = None
192+
self._srv_scheme: str = "http"
193+
self._srv_cache: tuple[str, int] | None = None
194+
self._srv_cache_time: float = 0.0
195+
self._srv_cache_ttl: float = 60.0
165196

166197
# If no adapter is provided, use TCPKeepAliveAdapter (default behavior)
167198
self.adapter = adapter
@@ -218,6 +249,9 @@ def get_conn(
218249
def _set_base_url(self, connection) -> None:
219250
host = connection.host or self.default_host
220251
schema = connection.schema or "http"
252+
extra = connection.extra_dejson
253+
self._srv_lookup_enabled = to_boolean(str(extra.get("srv_lookup", False)))
254+
self._srv_cache_ttl = float(extra.get("srv_cache_ttl", self._srv_cache_ttl))
221255
# RFC 3986 (https://www.rfc-editor.org/rfc/rfc3986.html#page-16)
222256
if "://" in host:
223257
self.base_url = host
@@ -228,8 +262,48 @@ def _set_base_url(self, connection) -> None:
228262
parsed = urlparse(self.base_url)
229263
if not parsed.scheme:
230264
raise ValueError(f"Invalid base URL: Missing scheme in {self.base_url}")
265+
if self._srv_lookup_enabled:
266+
# When SRV lookup is enabled, ``host`` is the SRV record name (e.g.
267+
# ``_http._tcp.example.com``), not a directly connectable hostname.
268+
self._srv_name = parsed.hostname
269+
self._srv_scheme = parsed.scheme
270+
self._srv_cache = None
271+
self._srv_cache_time = 0.0
231272
self._base_url_initialized = True
232273

274+
def _get_dynamic_base_url(self) -> str:
275+
"""Return the base URL for the current request, resolving SRV records when enabled."""
276+
if not self._srv_lookup_enabled:
277+
return self.base_url
278+
now = time.monotonic()
279+
if self._srv_cache is None or (now - self._srv_cache_time) >= self._srv_cache_ttl:
280+
self._srv_cache = self._resolve_srv_record(cast("str", self._srv_name))
281+
self._srv_cache_time = now
282+
target_host, target_port = self._srv_cache
283+
return f"{self._srv_scheme}://{target_host}:{target_port}"
284+
285+
def _resolve_srv_record(self, host: str) -> tuple[str, int]:
286+
"""
287+
Resolve a DNS SRV record to a target host and port.
288+
289+
Requires the optional ``dnspython`` dependency.
290+
"""
291+
try:
292+
import dns.exception
293+
import dns.resolver
294+
except ImportError as e:
295+
raise HttpSrvLookupException(
296+
"To use SRV DNS resolution in HttpHook, the 'dnspython' library must be installed. "
297+
"Install it via the 'srv' extra: pip install apache-airflow-providers-http[srv]"
298+
) from e
299+
300+
try:
301+
answers = dns.resolver.resolve(host, "SRV")
302+
except dns.exception.DNSException as e:
303+
self.log.error("Failed to resolve SRV record for %s: %s", host, e)
304+
raise HttpSrvLookupException(f"Failed to resolve SRV record for {host}: {e}") from e
305+
return _select_srv_target(answers)
306+
233307
def _configure_session_from_auth(self, session: Session, connection: Connection) -> Session:
234308
session.auth = self._extract_auth(connection)
235309
return session
@@ -407,12 +481,17 @@ def run_with_advanced_retry(self, _retry_args: dict[Any, Any], *args: Any, **kwa
407481
return self._retry_obj(self.run, *args, **kwargs)
408482

409483
def url_from_endpoint(self, endpoint: str | None) -> str:
410-
"""Combine base url with endpoint."""
484+
"""
485+
Combine base url with endpoint.
486+
487+
If SRV lookup is enabled on the connection, the base URL is re-resolved (subject to
488+
caching) before combining it with the endpoint.
489+
"""
411490
# Ensure base_url is set by initializing it if it hasn't been initialized yet
412491
if not self._base_url_initialized and not self.base_url:
413492
connection = self.get_connection(self.http_conn_id)
414493
self._set_base_url(connection)
415-
return _url_from_endpoint(base_url=self.base_url, endpoint=endpoint)
494+
return _url_from_endpoint(base_url=self._get_dynamic_base_url(), endpoint=endpoint)
416495

417496
def test_connection(self):
418497
"""Test HTTP Connection."""
@@ -509,7 +588,7 @@ async def run(
509588
"""
510589
from tenacity import AsyncRetrying, stop_after_attempt, wait_fixed
511590

512-
url = _url_from_endpoint(self.base_url, endpoint)
591+
url = _url_from_endpoint(await self._hook._get_dynamic_base_url_async(), endpoint)
513592
merged_headers = {**(self.headers or {}), **(headers or {})}
514593
extra_options = {**(self.extra_options or {}), **(extra_options or {})}
515594

@@ -558,6 +637,11 @@ class HttpAsyncHook(BaseHook):
558637
:param auth_type: The auth type for the service
559638
:param retry_limit: Maximum number of times to retry this job if it fails (default is 3)
560639
:param retry_delay: Delay between retry attempts (default is 1.0)
640+
641+
Extra also supports resolving ``host`` via a DNS SRV record:
642+
643+
* ``srv_lookup`` (bool): treat ``host`` as an SRV record name, e.g. ``_http._tcp.example.com``.
644+
* ``srv_cache_ttl`` (float): SRV cache TTL in seconds (default 60).
561645
"""
562646

563647
conn_name_attr = "http_conn_id"
@@ -583,6 +667,13 @@ def __init__(
583667
self.retry_limit = retry_limit
584668
self.retry_delay = retry_delay
585669
self._config: SessionConfig | None = None
670+
self._srv_lookup_enabled: bool = False
671+
self._srv_name: str | None = None
672+
self._srv_scheme: str = "http"
673+
self._srv_cache: tuple[str, int] | None = None
674+
self._srv_cache_time: float = 0.0
675+
self._srv_cache_ttl: float = 60.0
676+
self._srv_lock = asyncio.Lock()
586677

587678
def _get_request_func(
588679
self, session: aiohttp.ClientSession, method: str | None = None
@@ -634,6 +725,16 @@ async def config(self) -> SessionConfig:
634725
)
635726
headers.update(conn_extra_options)
636727

728+
extra = conn.extra_dejson
729+
self._srv_lookup_enabled = to_boolean(str(extra.get("srv_lookup", False)))
730+
self._srv_cache_ttl = float(extra.get("srv_cache_ttl", self._srv_cache_ttl))
731+
if self._srv_lookup_enabled:
732+
# When SRV lookup is enabled, ``host`` is the SRV record name (e.g.
733+
# ``_http._tcp.example.com``), not a directly connectable hostname.
734+
parsed = urlparse(base_url)
735+
self._srv_name = parsed.hostname
736+
self._srv_scheme = parsed.scheme
737+
637738
self._config = SessionConfig(
638739
base_url=base_url,
639740
headers=headers,
@@ -642,6 +743,45 @@ async def config(self) -> SessionConfig:
642743
)
643744
return self._config
644745

746+
async def _get_dynamic_base_url_async(self) -> str:
747+
"""Return the base URL for the current request, resolving SRV records when enabled."""
748+
config = await self.config()
749+
if not self._srv_lookup_enabled:
750+
return config.base_url
751+
now = time.monotonic()
752+
if self._srv_cache is None or (now - self._srv_cache_time) >= self._srv_cache_ttl:
753+
async with self._srv_lock:
754+
# Re-check after acquiring the lock: another concurrent request may have
755+
# already refreshed the cache while this one was waiting.
756+
now = time.monotonic()
757+
if self._srv_cache is None or (now - self._srv_cache_time) >= self._srv_cache_ttl:
758+
self._srv_cache = await self._resolve_srv_record_async(cast("str", self._srv_name))
759+
self._srv_cache_time = now
760+
target_host, target_port = self._srv_cache
761+
return f"{self._srv_scheme}://{target_host}:{target_port}"
762+
763+
async def _resolve_srv_record_async(self, host: str) -> tuple[str, int]:
764+
"""
765+
Resolve a DNS SRV record to a target host and port without blocking the event loop.
766+
767+
Requires the optional ``dnspython`` dependency.
768+
"""
769+
try:
770+
import dns.asyncresolver
771+
import dns.exception
772+
except ImportError as e:
773+
raise HttpSrvLookupException(
774+
"To use SRV DNS resolution in HttpAsyncHook, the 'dnspython' library must be installed. "
775+
"Install it via the 'srv' extra: pip install apache-airflow-providers-http[srv]"
776+
) from e
777+
778+
try:
779+
answers = await dns.asyncresolver.resolve(host, "SRV")
780+
except dns.exception.DNSException as e:
781+
self.log.error("Failed to resolve SRV record for %s: %s", host, e)
782+
raise HttpSrvLookupException(f"Failed to resolve SRV record for {host}: {e}") from e
783+
return _select_srv_target(answers)
784+
645785
@asynccontextmanager
646786
async def session(self, method: str | None = None) -> AsyncGenerator[AsyncHttpSession, None]:
647787
"""

0 commit comments

Comments
 (0)