55//!
66//! [`Upstream`]: praxis_core::connectivity::Upstream
77
8- use std:: sync:: Arc ;
8+ use std:: { net :: ToSocketAddrs , sync:: Arc } ;
99
1010use pingora_core:: { Result , upstreams:: peer:: HttpPeer } ;
1111use praxis_core:: connectivity:: Upstream ;
@@ -49,13 +49,7 @@ pub(super) fn execute(ctx: &mut PingoraRequestCtx) -> Result<Box<HttpPeer>> {
4949/// [`HttpPeer`]: pingora_core::upstreams::peer::HttpPeer
5050/// [`CachedClusterTls`]: praxis_tls::CachedClusterTls
5151fn build_peer ( upstream : & Upstream ) -> Result < Box < HttpPeer > > {
52- let addr: std:: net:: SocketAddr = upstream. address . parse ( ) . map_err ( |e| {
53- tracing:: warn!( address = %upstream. address, error = %e, "failed to parse upstream address" ) ;
54- pingora_core:: Error :: explain (
55- pingora_core:: ErrorType :: InternalError ,
56- "upstream address resolution failed" . to_owned ( ) ,
57- )
58- } ) ?;
52+ let addr: std:: net:: SocketAddr = resolve_address ( & upstream. address ) ?;
5953
6054 let tls_enabled = upstream. tls . is_some ( ) ;
6155 let sni = upstream
@@ -133,6 +127,57 @@ fn derive_sni(address: &str) -> String {
133127 host. to_owned ( )
134128}
135129
130+ /// Resolve an upstream address to a [`SocketAddr`].
131+ ///
132+ /// Tries direct [`SocketAddr`] parsing first. If that fails (e.g. the
133+ /// address contains a hostname like `api.openai.com:443`), falls back
134+ /// to [`ToSocketAddrs`] which performs DNS resolution.
135+ ///
136+ /// When DNS returns multiple records, prefers IPv4 addresses to avoid
137+ /// connectivity issues in dual-stack environments where IPv6 may be
138+ /// unreachable.
139+ ///
140+ /// Note: DNS resolution is synchronous and runs on the request path.
141+ /// A future iteration may move resolution to config/load-balancer
142+ /// time or integrate an async cached resolver.
143+ ///
144+ /// [`SocketAddr`]: std::net::SocketAddr
145+ /// [`ToSocketAddrs`]: std::net::ToSocketAddrs
146+ fn resolve_address ( address : & str ) -> Result < std:: net:: SocketAddr > {
147+ if let Ok ( addr) = address. parse :: < std:: net:: SocketAddr > ( ) {
148+ return Ok ( addr) ;
149+ }
150+
151+ let addrs: Vec < std:: net:: SocketAddr > = address
152+ . to_socket_addrs ( )
153+ . map_err ( |e| {
154+ tracing:: warn!( address, error = %e, "failed to resolve upstream address" ) ;
155+ pingora_core:: Error :: explain (
156+ pingora_core:: ErrorType :: InternalError ,
157+ format ! ( "upstream address resolution failed for '{address}': {e}" ) ,
158+ )
159+ } ) ?
160+ . collect ( ) ;
161+
162+ select_preferred_address ( & addrs, address)
163+ }
164+
165+ /// Select the preferred address from resolved results, favoring IPv4.
166+ fn select_preferred_address ( addrs : & [ std:: net:: SocketAddr ] , address : & str ) -> Result < std:: net:: SocketAddr > {
167+ addrs
168+ . iter ( )
169+ . find ( |a| a. is_ipv4 ( ) )
170+ . or_else ( || addrs. first ( ) )
171+ . copied ( )
172+ . ok_or_else ( || {
173+ tracing:: warn!( address, "DNS resolved but returned no addresses" ) ;
174+ pingora_core:: Error :: explain (
175+ pingora_core:: ErrorType :: InternalError ,
176+ format ! ( "upstream address '{address}' resolved to zero addresses" ) ,
177+ )
178+ } )
179+ }
180+
136181// -----------------------------------------------------------------------------
137182// Tests
138183// -----------------------------------------------------------------------------
@@ -145,6 +190,7 @@ fn derive_sni(address: &str) -> String {
145190 clippy:: field_reassign_with_default,
146191 clippy:: too_many_lines,
147192 clippy:: significant_drop_tightening,
193+ clippy:: print_stderr,
148194 reason = "tests"
149195) ]
150196mod tests {
@@ -250,11 +296,73 @@ mod tests {
250296 ) ;
251297 }
252298
299+ #[ test]
300+ fn resolve_address_parses_socket_addr ( ) {
301+ let addr = resolve_address ( "127.0.0.1:8080" ) . expect ( "socket addr should parse" ) ;
302+ assert_eq ! ( addr. port( ) , 8080 , "port should match" ) ;
303+ }
304+
305+ #[ test]
306+ fn resolve_address_resolves_localhost ( ) {
307+ if !localhost_resolution_available ( ) {
308+ eprintln ! ( "skipping: localhost did not resolve in this environment" ) ;
309+ return ;
310+ }
311+ let addr = resolve_address ( "localhost:8080" ) . expect ( "localhost should resolve" ) ;
312+ assert_eq ! ( addr. port( ) , 8080 , "port should match" ) ;
313+ }
314+
315+ #[ test]
316+ fn resolve_address_fails_for_no_port ( ) {
317+ assert ! (
318+ resolve_address( "127.0.0.1" ) . is_err( ) ,
319+ "address without port should return error"
320+ ) ;
321+ }
322+
323+ #[ test]
324+ fn hostname_address_builds_peer ( ) {
325+ if !localhost_resolution_available ( ) {
326+ eprintln ! ( "skipping: localhost did not resolve in this environment" ) ;
327+ return ;
328+ }
329+ assert ! (
330+ build_peer( & make_upstream( "localhost:8080" ) ) . is_ok( ) ,
331+ "hostname address should build peer via DNS resolution"
332+ ) ;
333+ }
334+
335+ #[ test]
336+ fn select_preferred_address_prefers_ipv4_from_mixed_results ( ) {
337+ let ipv6: std:: net:: SocketAddr = "[::1]:8080" . parse ( ) . unwrap ( ) ;
338+ let ipv4: std:: net:: SocketAddr = "127.0.0.1:8080" . parse ( ) . unwrap ( ) ;
339+ let selected =
340+ select_preferred_address ( & [ ipv6, ipv4] , "mixed.example:8080" ) . expect ( "mixed results should select address" ) ;
341+ assert_eq ! ( selected, ipv4, "IPv4 should be preferred over IPv6" ) ;
342+ }
343+
344+ #[ test]
345+ fn select_preferred_address_returns_ipv6_when_ipv6_only ( ) {
346+ let ipv6: std:: net:: SocketAddr = "[::1]:8080" . parse ( ) . unwrap ( ) ;
347+ let selected =
348+ select_preferred_address ( & [ ipv6] , "ipv6.example:8080" ) . expect ( "IPv6-only results should select IPv6" ) ;
349+ assert_eq ! ( selected, ipv6, "IPv6 should be used when it is the only result" ) ;
350+ }
351+
352+ #[ test]
353+ fn select_preferred_address_errors_on_empty_results ( ) {
354+ let err = select_preferred_address ( & [ ] , "empty.example:8080" ) . expect_err ( "empty DNS result should fail" ) ;
355+ assert ! (
356+ err. to_string( ) . contains( "resolved to zero addresses" ) ,
357+ "unexpected error: {err}"
358+ ) ;
359+ }
360+
253361 #[ test]
254362 fn invalid_address_returns_error ( ) {
255363 assert ! (
256- build_peer( & make_upstream( "not-an-address " ) ) . is_err( ) ,
257- "invalid address should return error"
364+ build_peer( & make_upstream( "invalid host:8080 " ) ) . is_err( ) ,
365+ "syntactically invalid address should return error"
258366 ) ;
259367 }
260368
@@ -367,6 +475,13 @@ mod tests {
367475 // Test Utilities
368476 // -------------------------------------------------------------------------
369477
478+ /// Check whether `localhost` DNS resolution is available in this environment.
479+ fn localhost_resolution_available ( ) -> bool {
480+ "localhost:8080"
481+ . to_socket_addrs ( )
482+ . is_ok_and ( |mut addrs| addrs. next ( ) . is_some ( ) )
483+ }
484+
370485 /// Create a test upstream with the given address (no TLS).
371486 fn make_upstream ( address : & str ) -> Upstream {
372487 Upstream {
0 commit comments