1717# under the License.
1818from __future__ import annotations
1919
20+ import asyncio
2021import copy
21- from collections .abc import AsyncGenerator , Awaitable , Callable
22+ import random
23+ import time
24+ from collections .abc import AsyncGenerator , Awaitable , Callable , Iterable
2225from contextlib import asynccontextmanager
2326from typing import TYPE_CHECKING , Any , cast
2427from urllib .parse import urlparse
3538from tenacity import retry_if_exception
3639
3740from 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
3942from airflow .utils .log .logging_mixin import LoggingMixin
43+ from airflow .utils .strings import to_boolean
4044
4145if 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+
5572def _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