Summary
On Ruby 4.0.0, Net::LDAP#bind may raise IO::TimeoutError directly when connect_timeout is used,
instead of being wrapped as Net::LDAP::Error.
With the same net-ldap version (0.20.0), Ruby 3.4.7 wraps the timeout as Net::LDAP::Error,
so this appears to be a behavioral regression caused by changes in Ruby 4.0.
This breaks existing code that rescues Net::LDAP::Error for connection failures.
Steps to reproduce
$ ruby -v
# ruby 3.4.7
# ruby 4.0.0
$ gem install net-ldap -v 0.20.0
# reproduce.rb
require "net/ldap"
ldap = Net::LDAP.new(
host: "example.com",
port: 389,
connect_timeout: 1,
auth: {
method: :simple,
username: "cn=dummy,dc=example,dc=com",
password: "dummy",
},
)
ldap.bind
Expected behavior (Ruby 3.4.7)
With net-ldap 0.20.0 on Ruby 3.4.7, connection timeouts are wrapped by net-ldap and raised as
Net::LDAP::Error from Net::LDAP::Connection#open_connection.
Example stack trace:
/path/to/gems/net-ldap-0.20.0/lib/net/ldap/connection.rb:72:in `Net::LDAP::Connection#open_connection':
Connection timed out - user specified timeout (Net::LDAP::Error)
from /path/to/gems/net-ldap-0.20.0/lib/net/ldap/connection.rb:736:in `Net::LDAP::Connection#socket'
from /path/to/gems/net-ldap-0.20.0/lib/net/ldap.rb:1349:in `Net::LDAP#new_connection'
from /path/to/gems/net-ldap-0.20.0/lib/net/ldap.rb:869:in `block in Net::LDAP#bind'
from /path/to/gems/net-ldap-0.20.0/lib/net/ldap/instrumentation.rb:19:in `Net::LDAP::Instrumentation#instrument'
from /path/to/gems/net-ldap-0.20.0/lib/net/ldap.rb:863:in `Net::LDAP#bind'
from reproduce.rb:14:in `<main>'
This behavior is considered the expected behavior for compatibility, as existing applications
commonly rescue Net::LDAP::Error.
Actual behavior (Ruby 4.0.0)
On Ruby 4.0.0, IO::TimeoutError is raised directly from Ruby’s socket implementation and is not
wrapped by net-ldap:
/path/to/ruby/4.0.0/lib/ruby/4.0.0/socket.rb:923:in `block in Socket.tcp_with_fast_fallback':
user specified timeout for example.com:389 (IO::TimeoutError)
from /path/to/ruby/4.0.0/lib/ruby/4.0.0/socket.rb:731:in `Kernel#loop'
from /path/to/ruby/4.0.0/lib/ruby/4.0.0/socket.rb:731:in `Socket.tcp_with_fast_fallback'
from /path/to/ruby/4.0.0/lib/ruby/4.0.0/socket.rb:669:in `Socket.tcp'
from /path/to/gems/net-ldap-0.20.0/lib/net/ldap/connection.rb:747:in `Net::LDAP::Connection::DefaultSocket.new'
from /path/to/gems/net-ldap-0.20.0/lib/net/ldap/connection.rb:53:in `block in Net::LDAP::Connection#open_connection'
from /path/to/gems/net-ldap-0.20.0/lib/net/ldap/connection.rb:51:in `Array#each'
from /path/to/gems/net-ldap-0.20.0/lib/net/ldap/connection.rb:51:in `Net::LDAP::Connection#open_connection'
from /path/to/gems/net-ldap-0.20.0/lib/net/ldap/connection.rb:736:in `Net::LDAP::Connection#socket'
from /path/to/gems/net-ldap-0.20.0/lib/net/ldap.rb:1349:in `Net::LDAP#new_connection'
from /path/to/gems/net-ldap-0.20.0/lib/net/ldap.rb:869:in `block in Net::LDAP#bind'
from /path/to/gems/net-ldap-0.20.0/lib/net/ldap/instrumentation.rb:19:in `Net::LDAP::Instrumentation#instrument'
from /path/to/gems/net-ldap-0.20.0/lib/net/ldap.rb:863:in `Net::LDAP#bind'
from reproduce.rb:14:in `<main>'
Why this happens (analysis)
Net::LDAP::Connection#open_connection rescues the following exceptions:
Net::LDAP::Error
SocketError
SystemCallError
OpenSSL::SSL::SSLError
On Ruby 4.0.0, Socket.tcp(..., connect_timeout: ...) may raise IO::TimeoutError
(from Socket.tcp_with_fast_fallback), which is not a subclass of SystemCallError.
As a result, the exception bypasses the rescue clause and bubbles up to the caller.
This behavior change appears to be triggered by a change in Ruby 4.0's socket timeout handling.
In particular, Ruby PR #15582 modified how user-specified timeouts are handled in Socket.tcp,
causing IO::TimeoutError to be raised for connection timeouts.
Reference:
ruby/ruby#15582
Proposed fix
Include IO::TimeoutError in the rescue list of Net::LDAP::Connection#open_connection:
rescue Net::LDAP::Error,
SocketError,
SystemCallError,
OpenSSL::SSL::SSLError,
+ IO::TimeoutError => e
System configuration
- Ruby: 3.4.7 (expected), 4.0.0 (actual)
- net-ldap: 0.20.0
Summary
On Ruby 4.0.0,
Net::LDAP#bindmay raiseIO::TimeoutErrordirectly whenconnect_timeoutis used,instead of being wrapped as
Net::LDAP::Error.With the same net-ldap version (0.20.0), Ruby 3.4.7 wraps the timeout as
Net::LDAP::Error,so this appears to be a behavioral regression caused by changes in Ruby 4.0.
This breaks existing code that rescues
Net::LDAP::Errorfor connection failures.Steps to reproduce
Expected behavior (Ruby 3.4.7)
With net-ldap 0.20.0 on Ruby 3.4.7, connection timeouts are wrapped by net-ldap and raised as
Net::LDAP::ErrorfromNet::LDAP::Connection#open_connection.Example stack trace:
This behavior is considered the expected behavior for compatibility, as existing applications
commonly rescue
Net::LDAP::Error.Actual behavior (Ruby 4.0.0)
On Ruby 4.0.0,
IO::TimeoutErroris raised directly from Ruby’s socket implementation and is notwrapped by net-ldap:
Why this happens (analysis)
Net::LDAP::Connection#open_connectionrescues the following exceptions:Net::LDAP::ErrorSocketErrorSystemCallErrorOpenSSL::SSL::SSLErrorOn Ruby 4.0.0,
Socket.tcp(..., connect_timeout: ...)may raiseIO::TimeoutError(from
Socket.tcp_with_fast_fallback), which is not a subclass ofSystemCallError.As a result, the exception bypasses the rescue clause and bubbles up to the caller.
This behavior change appears to be triggered by a change in Ruby 4.0's socket timeout handling.
In particular, Ruby PR #15582 modified how user-specified timeouts are handled in
Socket.tcp,causing
IO::TimeoutErrorto be raised for connection timeouts.Reference:
ruby/ruby#15582
Proposed fix
Include
IO::TimeoutErrorin the rescue list ofNet::LDAP::Connection#open_connection:rescue Net::LDAP::Error, SocketError, SystemCallError, OpenSSL::SSL::SSLError, + IO::TimeoutError => eSystem configuration