faraday-happy-eyeballs 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 457ecae8f7ee6fe96b4e54ded90e335e3992c9cb7a988f3f8846ea74642b4f76
4
+ data.tar.gz: d30d85f69685f3e5edf6cc383857c87bd51f1c10013f2325f0198f29a6960f1e
5
+ SHA512:
6
+ metadata.gz: a447249ce1d028c712a91fb9ee9518157614fb3233f34118d301ac7eac5f17671982b674b958df7df2b6c74bde1f6771fc4e4d934de4a9b58d9f24f364cf91ef
7
+ data.tar.gz: 205f053fcc3e18b5d2670fee13b73297aa15d9bfa951b968e25681ba1ef1af0f86bbe7bc57178439449427d9b73a194a255f6dc013cc74ed49486bca96f24a78
data/.rspec ADDED
@@ -0,0 +1,3 @@
1
+ --require spec_helper
2
+ --color
3
+ --format documentation
data/.rubocop.yml ADDED
@@ -0,0 +1,130 @@
1
+ AllCops:
2
+ NewCops: enable
3
+ TargetRubyVersion: 2.7
4
+ SuggestExtensions: false
5
+ Exclude:
6
+ - "vendor/**/*"
7
+ - "pkg/**/*"
8
+
9
+ Style/Documentation:
10
+ Enabled: false
11
+
12
+ Style/FrozenStringLiteralComment:
13
+ EnforcedStyle: always
14
+
15
+ Style/StringLiterals:
16
+ EnforcedStyle: double_quotes
17
+
18
+ Metrics/MethodLength:
19
+ Max: 40
20
+
21
+ Metrics/AbcSize:
22
+ Max: 45
23
+
24
+ Metrics/ClassLength:
25
+ Max: 200
26
+
27
+ Metrics/BlockLength:
28
+ Exclude:
29
+ - "spec/**/*"
30
+
31
+ Layout/LineLength:
32
+ Max: 130
33
+
34
+ Metrics/CyclomaticComplexity:
35
+ Max: 18
36
+
37
+ Metrics/PerceivedComplexity:
38
+ Max: 18
39
+
40
+ # This codebase intentionally uses a compact, single-space-indent style
41
+ # throughout (not unique to this gem - it's the author's house style across
42
+ # the whole project this gem was extracted from). The cops below are purely
43
+ # about indentation width/alignment and formatting density; disabling them
44
+ # avoids fighting a deliberate, consistently-applied style rather than
45
+ # flagging real bugs or public-API concerns.
46
+ Layout/IndentationWidth:
47
+ Enabled: false
48
+ Layout/IndentationConsistency:
49
+ Enabled: false
50
+ Layout/CaseIndentation:
51
+ Enabled: false
52
+ Layout/AccessModifierIndentation:
53
+ Enabled: false
54
+ Layout/EndAlignment:
55
+ Enabled: false
56
+ Layout/DefEndAlignment:
57
+ Enabled: false
58
+ Layout/BeginEndAlignment:
59
+ Enabled: false
60
+ Layout/BlockEndNewline:
61
+ Enabled: false
62
+ Layout/ExtraSpacing:
63
+ Enabled: false
64
+ Layout/EmptyLineAfterMagicComment:
65
+ Enabled: false
66
+ Layout/EmptyLinesAroundAccessModifier:
67
+ Enabled: false
68
+ Layout/RescueEnsureAlignment:
69
+ Enabled: false
70
+ Layout/FirstHashElementIndentation:
71
+ Enabled: false
72
+ Layout/HashAlignment:
73
+ Enabled: false
74
+ Layout/MultilineOperationIndentation:
75
+ Enabled: false
76
+ Layout/MultilineMethodCallIndentation:
77
+ Enabled: false
78
+ Layout/MultilineHashBraceLayout:
79
+ Enabled: false
80
+ Layout/SpaceInsideParens:
81
+ Enabled: false
82
+ Layout/EmptyLineAfterGuardClause:
83
+ Enabled: false
84
+ Layout/EmptyLinesAroundBlockBody:
85
+ Enabled: false
86
+ Layout/EmptyLinesAroundExceptionHandlingKeywords:
87
+ Enabled: false
88
+ Layout/EmptyLineBetweenDefs:
89
+ Enabled: false
90
+ Style/TrailingMethodEndStatement:
91
+ Enabled: false
92
+ Style/SingleLineDoEndBlock:
93
+ Enabled: false
94
+ Style/SingleLineMethods:
95
+ Enabled: false
96
+ Style/BlockDelimiters:
97
+ Enabled: false
98
+ Style/ColonMethodCall:
99
+ Enabled: false
100
+ Style/CommentedKeyword:
101
+ Enabled: false
102
+ Style/IfUnlessModifier:
103
+ Enabled: false
104
+ Style/RescueModifier:
105
+ Enabled: false
106
+ Style/RescueStandardError:
107
+ Enabled: false
108
+ Style/ConditionalAssignment:
109
+ Enabled: false
110
+ Style/TernaryParentheses:
111
+ Enabled: false
112
+ Style/RedundantBegin:
113
+ Enabled: false
114
+ Style/RedundantReturn:
115
+ Enabled: false
116
+ Style/NumericPredicate:
117
+ Enabled: false
118
+ Style/MapIntoArray:
119
+ Enabled: false
120
+ Style/SafeNavigation:
121
+ Enabled: false
122
+ Lint/ScriptPermission:
123
+ Enabled: false
124
+ Lint/IncompatibleIoSelectWithFiberScheduler:
125
+ Enabled: false
126
+
127
+ # `has_connection?` etc. are established public API; renaming to satisfy
128
+ # this cop would be a breaking change for zero functional benefit.
129
+ Naming/PredicatePrefix:
130
+ Enabled: false
data/CHANGELOG.md ADDED
@@ -0,0 +1,73 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented in this file.
4
+
5
+ ## [0.3.0] - 2026-08-26
6
+
7
+ ### Fixed
8
+ - **Critical**: DNS resolution no longer discards an entire address family once
9
+ the other family resolves. Previously, if `prefer_ipv6` resolved any AAAA
10
+ records, all A records were thrown away before the connection race ever
11
+ ran - so on any host with no real IPv6 route (a very common environment:
12
+ many corporate networks, VPNs, and containers), every single request
13
+ failed, even though working IPv4 addresses were available. DNS answering
14
+ for a family says nothing about whether that family is actually routable;
15
+ that determination now correctly happens during the connection race, not
16
+ during resolution.
17
+ - Fixed a file path collision: the adapter previously shipped as
18
+ `lib/faraday/adapter.rb`, which is the exact path Faraday's own gem uses
19
+ for its base `Faraday::Adapter` class. Depending on gem load-path
20
+ ordering, `require "faraday/adapter"` could resolve to this gem's file
21
+ instead of Faraday's, raising `uninitialized constant Faraday::Adapter`.
22
+ All adapter/pool/resolver files now live under `lib/faraday/happy_eyeballs/`,
23
+ matching the module namespace.
24
+ - `dual_stack_resolver.rb`, `adapter.rb`, and `connection_pool.rb` now
25
+ `require` the stdlib they each use (`resolv`, `timeout`, `socket`,
26
+ `net/http`, `openssl`) instead of relying on the entry-point file having
27
+ required them first.
28
+ - Fixed the "stop launching more connection attempts once one succeeds"
29
+ early-exit, which called `break` inside a `Mutex#synchronize` block - that
30
+ only exits the `synchronize` block, not the enclosing loop, so every
31
+ candidate address was still attempted regardless of an earlier success.
32
+ - The connection-attempt wait loop no longer blocks for the full
33
+ `connection_timeout` when every candidate has already failed; it now
34
+ exits as soon as no attempt threads remain.
35
+ - `connection_timeout` now actually bounds the real HTTP request
36
+ (`open_timeout`/`read_timeout`/`write_timeout` on the underlying
37
+ `Net::HTTP`), not just the Happy Eyeballs connection race. Previously a
38
+ slow or unresponsive server could hang for `Net::HTTP`'s 60s default
39
+ regardless of the configured `connection_timeout`.
40
+ - Replaced `http.instance_variable_set(:@ipaddr, ...)` with the public
41
+ `Net::HTTP#ipaddr=` API for dialing a specific address while keeping the
42
+ original hostname for SNI/Host.
43
+
44
+ ### Added
45
+ - Connection result caching (`enable_cache`/`cache_ttl`) is now actually
46
+ wired into the request path. Previously `ConnectionPool` was instantiated
47
+ but never consulted, so every request re-ran the full DNS + connection
48
+ race regardless of the option. A cached address that later fails triggers
49
+ one fresh race rather than a hard failure.
50
+ - DNS resolution results are now cached per-hostname (respecting
51
+ `cache_ttl`), reusing the previously-unused `@resolution_cache`.
52
+ - `Socket.getaddrinfo`-based direct resolution is now used as a genuine
53
+ last-resort fallback when manual `Resolv::DNS` A/AAAA lookups return
54
+ nothing.
55
+ - Test suite (RSpec), Rakefile, `.rspec`, RuboCop config, and a GitHub
56
+ Actions CI workflow.
57
+ - `LICENSE.txt`.
58
+
59
+ ### Changed
60
+ - `required_ruby_version` corrected from `>= 4.0.0` (a local-machine
61
+ artifact) to `>= 2.7.0`, matching the README's documented compatibility
62
+ and the actual syntax used in the gem.
63
+ - Gemspec `homepage`/`source_code_uri`/`changelog_uri` corrected to match
64
+ the actual repository name (`happy_eye_balls`), which had diverged from
65
+ the gem name (`faraday-happy-eyeballs`).
66
+
67
+ ## [0.2.0] - 2026-08-20
68
+ - Adjusted CA cert handling for HTTPS requests.
69
+
70
+ ## [0.1.0] - 2025-06-30 to 2025-08-19
71
+ - Initial gem structure, adapter implementation (moved from a Faraday
72
+ middleware to a Faraday adapter), dual-stack DNS resolution, connection
73
+ pooling, and debug logging via `DEBUG_HAPPYEYES`.
data/Gemfile ADDED
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ source "https://rubygems.org"
4
+
5
+ gemspec
6
+
7
+ group :development, :test do
8
+ gem "rake", "~> 13.0"
9
+ gem "rspec", "~> 3.0"
10
+ gem "rubocop", "~> 1.21"
11
+ gem "webmock", "~> 3.0"
12
+ end
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 MarkT
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,203 @@
1
+ # Faraday Happy Eyeballs
2
+
3
+ A Faraday adapter that implements the Happy Eyeballs algorithm (RFC 6555) for intelligent IPv4/IPv6 dual-stack connections.
4
+
5
+ ## What is Happy Eyeballs?
6
+
7
+ Happy Eyeballs (RFC 6555) is an algorithm that makes dual-stack applications (IPv4 and IPv6) more responsive by racing IPv4 and IPv6 connection attempts. Instead of waiting for one protocol to timeout before trying the other, it attempts both simultaneously with intelligent timing to minimize connection delays.
8
+
9
+ ## Features
10
+
11
+ - **RFC 6555 Compliant**: Implements the official Happy Eyeballs algorithm
12
+ - **Automatic Fallback**: Seamlessly falls back between IPv6 and IPv4
13
+ - **Connection Caching**: Caches successful connection preferences to avoid repeated resolution
14
+ - **Configurable Timing**: Customize connection delays and timeouts
15
+ - **Thread Safety**: Safe for concurrent use
16
+ - **Small Dependency Surface**: Requires Faraday and Ruby standard library
17
+
18
+ ## Installation
19
+
20
+ Add this line to your application's Gemfile:
21
+
22
+ ```ruby
23
+ gem 'faraday-happy-eyeballs'
24
+ ```
25
+
26
+ And then execute:
27
+
28
+ ```bash
29
+ $ bundle install
30
+ ```
31
+
32
+ Or install it yourself as:
33
+
34
+ ```bash
35
+ $ gem install faraday-happy-eyeballs
36
+ ```
37
+
38
+ ## Usage
39
+
40
+ ### Basic Usage
41
+
42
+ ```ruby
43
+ require 'faraday'
44
+ require 'faraday/happy_eyeballs'
45
+
46
+ client = Faraday.new('https://example.com') do |faraday|
47
+ faraday.adapter :happy_eyeballs
48
+ end
49
+
50
+ response = client.get('/')
51
+ ```
52
+
53
+ ### Custom Configuration
54
+
55
+ ```ruby
56
+ client = Faraday.new('https://example.com') do |faraday|
57
+ faraday.adapter :happy_eyeballs, {
58
+ connection_attempt_delay: 0.25, # Delay between connection attempts (seconds)
59
+ resolution_delay: 0.05, # Delay between DNS resolutions (seconds)
60
+ first_address_family_count: 1, # Number of preferred family addresses to try first
61
+ connection_timeout: 10, # Total timeout for all connection attempts
62
+ cache_ttl: 300, # Cache TTL for successful connections (seconds)
63
+ prefer_ipv6: true, # Prefer IPv6 addresses (RFC recommendation)
64
+ enable_cache: true, # Enable connection result caching
65
+ ca_file: '/path/to/custom-ca-bundle.pem' # Optional CA bundle for HTTPS
66
+ }
67
+ end
68
+
69
+ # The CA file can also be supplied through Faraday's SSL options:
70
+ client = Faraday.new('https://example.com', ssl: {
71
+ ca_file: '/path/to/custom-ca-bundle.pem'
72
+ }) do |faraday|
73
+ faraday.adapter :happy_eyeballs
74
+ end
75
+ ```
76
+
77
+ ### Stacking with Other Middleware
78
+
79
+ ```ruby
80
+ client = Faraday.new('https://example.com') do |faraday|
81
+ faraday.use :retry, max: 3
82
+ faraday.use :gzip
83
+ faraday.adapter :happy_eyeballs, prefer_ipv6: false
84
+ end
85
+ ```
86
+
87
+ ### Twilio Ruby Client
88
+
89
+ The Twilio constructor does not take Faraday SSL options. Configure the
90
+ Twilio HTTP client before making a request:
91
+
92
+ ```ruby
93
+ twilio = Twilio::REST::Client.new(api_key_sid, api_secret, account_sid)
94
+
95
+ twilio.http_client.configure_connection do |faraday|
96
+ faraday.ssl[:ca_file] = '/path/to/custom-ca-bundle.pem'
97
+ end
98
+
99
+ twilio.http_client.adapter = :happy_eyeballs
100
+ ```
101
+
102
+ For Account SID plus Auth Token authentication, use:
103
+
104
+ ```ruby
105
+ twilio = Twilio::REST::Client.new(account_sid, auth_token)
106
+ twilio.http_client.adapter = :happy_eyeballs
107
+ ```
108
+
109
+ ### IPv4-Only Preference
110
+
111
+ ```ruby
112
+ client = Faraday.new('https://example.com') do |faraday|
113
+ faraday.adapter :happy_eyeballs, {
114
+ prefer_ipv6: false, # Prefer IPv4
115
+ connection_attempt_delay: 0.1, # Faster attempts for IPv4 preference
116
+ first_address_family_count: 2 # Try more IPv4 addresses first
117
+ }
118
+ end
119
+ ```
120
+
121
+ ## Configuration Options
122
+
123
+ | Option | Type | Default | Description |
124
+ |--------|------|---------|-------------|
125
+ | `connection_attempt_delay` | Float | 0.25 | Delay in seconds between connection attempts |
126
+ | `resolution_delay` | Float | 0.05 | Delay in seconds between IPv6 and IPv4 DNS resolution |
127
+ | `first_address_family_count` | Integer | 1 | Number of preferred address family addresses to try first |
128
+ | `connection_timeout` | Float | 10 | Total timeout in seconds for all connection attempts |
129
+ | `cache_ttl` | Integer | 300 | Time-to-live in seconds for cached connection preferences |
130
+ | `prefer_ipv6` | Boolean | true | Whether to prefer IPv6 addresses (RFC 6555 recommendation) |
131
+ | `enable_cache` | Boolean | true | Whether to cache successful connection results |
132
+ | `ca_file` | String | detected | CA bundle path used for HTTPS certificate verification |
133
+
134
+ ## How It Works
135
+
136
+ 1. **DNS Resolution**: Simultaneously resolves both IPv4 (A) and IPv6 (AAAA) records with a small delay favoring the preferred protocol
137
+ 2. **Address Sorting**: Sorts addresses according to preference (IPv6 first by default, as per RFC)
138
+ 3. **Connection Racing**: Attempts connections with staggered timing:
139
+ - Starts with the first address of the preferred family
140
+ - After a delay, starts attempting alternate addresses
141
+ - Continues until a connection succeeds or all attempts fail
142
+ 4. **HTTP Request**: Uses the successful connection to make the actual HTTP request
143
+ 5. **Caching**: Caches successful connection information to speed up future requests
144
+
145
+ For HTTPS, the adapter preserves the original hostname for SNI and certificate
146
+ hostname verification while dialing the address selected by the connection
147
+ race. Certificate verification remains enabled.
148
+
149
+ ## Error Handling
150
+
151
+ The adapter raises standard Faraday exceptions:
152
+
153
+ - `Faraday::ConnectionFailed`: When all connection attempts fail
154
+ - `Faraday::TimeoutError`: When the overall connection timeout is exceeded
155
+
156
+ ```ruby
157
+ begin
158
+ response = client.get('/')
159
+ rescue Faraday::ConnectionFailed => e
160
+ puts "All connections failed: #{e.message}"
161
+ rescue Faraday::TimeoutError => e
162
+ puts "Connection timed out: #{e.message}"
163
+ end
164
+ ```
165
+
166
+ ## Performance Considerations
167
+
168
+ - **First Request**: May be slightly slower due to DNS resolution and connection racing
169
+ - **Subsequent Requests**: Faster due to connection caching
170
+ - **Memory Usage**: Minimal - only caches successful connection metadata
171
+ - **Thread Safety**: Safe for concurrent use across multiple threads
172
+
173
+ ## Compatibility
174
+
175
+ - **Ruby**: >= 2.7.0
176
+ - **Faraday**: >= 1.0
177
+ - **IPv6 Support**: Requires system IPv6 support for full functionality
178
+
179
+ ## Contributing
180
+
181
+ 1. Fork the repository
182
+ 2. Create your feature branch (`git checkout -b my-new-feature`)
183
+ 3. Commit your changes (`git commit -am 'Add some feature'`)
184
+ 4. Push to the branch (`git push origin my-new-feature`)
185
+ 5. Create a new Pull Request
186
+
187
+ ## Testing
188
+
189
+ ```bash
190
+ bundle exec rspec
191
+ ```
192
+
193
+ ## Debug
194
+ export DEBUG_HAPPYEYES='true' # puts progress
195
+
196
+ ## License
197
+
198
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
199
+
200
+ ## References
201
+
202
+ - [RFC 6555: Happy Eyeballs](https://tools.ietf.org/html/rfc6555)
203
+ - [Faraday HTTP Client](https://github.com/lostisland/faraday)
data/Rakefile ADDED
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rspec/core/rake_task"
5
+
6
+ RSpec::Core::RakeTask.new(:spec)
7
+
8
+ begin
9
+ require "rubocop/rake_task"
10
+ RuboCop::RakeTask.new
11
+ rescue LoadError
12
+ nil
13
+ end
14
+
15
+ task default: %i[spec rubocop]
data/example_usage.rb ADDED
@@ -0,0 +1,103 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "faraday"
5
+ require "faraday/happy_eyeballs"
6
+
7
+ # Example 1: Basic usage with default settings
8
+ puts "=== Basic Happy Eyeballs Example ==="
9
+
10
+ client = Faraday.new("https://www.google.com") do |faraday|
11
+ faraday.adapter :happy_eyeballs end
12
+
13
+ begin
14
+ response = client.get("/")
15
+ puts "✓ Connection successful! Status: #{response.status}"
16
+ puts "✓ Happy Eyeballs automatically chose the fastest IP version"
17
+ rescue => e
18
+ puts "✗ Connection failed: #{e.message}" end
19
+
20
+ # Example 2: Custom configuration
21
+ puts "\n=== Custom Configuration Example ==="
22
+
23
+ client = Faraday.new("https://httpbin.org") do |faraday|
24
+ faraday.adapter :happy_eyeballs, {
25
+ connection_attempt_delay: 0.1, # Faster attempts (100ms instead of 250ms)
26
+ prefer_ipv6: false, # Prefer IPv4
27
+ connection_timeout: 5, # 5 second total timeout
28
+ cache_ttl: 600, # Cache successful connections for 10 minutes
29
+ enable_cache: true # Enable connection caching
30
+ }
31
+ end
32
+
33
+ begin
34
+ response = client.get("/get")
35
+ puts "✓ Custom configuration successful! Status: #{response.status}"
36
+ rescue => e
37
+ puts "✗ Connection failed: #{e.message}" end
38
+
39
+ # Example 3: Multiple requests showing caching
40
+ puts "\n=== Connection Caching Example ==="
41
+
42
+ client = Faraday.new("https://httpbin.org") do |faraday|
43
+ faraday.adapter :happy_eyeballs, enable_cache: true end
44
+
45
+ 3.times do |i|
46
+ begin
47
+ start_time = Time.now
48
+ response = client.get("/get")
49
+ end_time = Time.now
50
+
51
+ puts "✓ Request #{i + 1}: Status #{response.status}, Time: #{(end_time - start_time).round(3)}s"
52
+ puts " (Subsequent requests should be faster due to caching)"
53
+ rescue => e
54
+ puts "✗ Request #{i + 1} failed: #{e.message}" end
55
+ sleep(1) if i < 2 end
56
+
57
+ # Example 4: Error handling
58
+ puts "\n=== Error Handling Example ==="
59
+
60
+ client = Faraday.new("https://nonexistent-domain-12345.com") do |faraday|
61
+ faraday.adapter :happy_eyeballs end
62
+
63
+ begin
64
+ response = client.get("/")
65
+ puts "✓ Unexpected success: #{response.status}"
66
+ rescue Faraday::ConnectionFailed => e
67
+ puts "✗ Expected connection failure: #{e.message}"
68
+ rescue Faraday::TimeoutError => e
69
+ puts "✗ Expected timeout: #{e.message}"
70
+ rescue => e
71
+ puts "✗ Other error: #{e.message}" end
72
+
73
+ # Example 5: IPv4-only configuration
74
+ puts "\n=== IPv4-Only Configuration Example ==="
75
+
76
+ client = Faraday.new("https://www.cloudflare.com") do |faraday|
77
+ faraday.adapter :happy_eyeballs, {
78
+ prefer_ipv6: false, # Prefer IPv4
79
+ connection_attempt_delay: 0.05, # Very fast IPv4 preference
80
+ first_address_family_count: 2 # Try 2 IPv4 addresses before IPv6
81
+ } end
82
+
83
+ begin
84
+ response = client.get("/")
85
+ puts "✓ IPv4-preferred connection successful! Status: #{response.status}"
86
+ rescue => e
87
+ puts "✗ Connection failed: #{e.message}" end
88
+
89
+ # Example 6: Custom CA file
90
+ puts "\n=== Custom CA File Example ==="
91
+
92
+ client = Faraday.new("https://example.com", ssl: {
93
+ ca_file: "/path/to/custom-ca-bundle.pem"
94
+ }) do |faraday|
95
+ faraday.adapter :happy_eyeballs end
96
+
97
+ # Twilio uses its own Faraday connection. Configure SSL through its HTTP client:
98
+ # twilio = Twilio::REST::Client.new(api_key_sid, api_secret, account_sid)
99
+ # twilio.http_client.configure_connection do |faraday|
100
+ # faraday.ssl[:ca_file] = '/path/to/custom-ca-bundle.pem' end
101
+ # twilio.http_client.adapter = :happy_eyeballs
102
+
103
+ puts "\n=== Happy Eyeballs Examples Complete ==="
@@ -0,0 +1,212 @@
1
+ # frozen_string_literal: true
2
+ require "socket"
3
+ require "timeout"
4
+ require "net/http"
5
+ require "openssl"
6
+ module Faraday
7
+ module HappyEyeballs
8
+ class Adapter < Faraday::Adapter
9
+ NET_HTTP_REQUEST_CLASSES = {
10
+ "GET" => Net::HTTP::Get, "POST" => Net::HTTP::Post, "PUT" => Net::HTTP::Put,
11
+ "DELETE" => Net::HTTP::Delete, "PATCH" => Net::HTTP::Patch, "HEAD" => Net::HTTP::Head
12
+ }.freeze
13
+
14
+ def initialize(app = nil, options = {})
15
+ super(app)
16
+ @options = { connection_attempt_delay: 0.25, resolution_delay: 0.05, first_address_family_count: 1,
17
+ connection_timeout: 10, cache_ttl: 300, prefer_ipv6: true, enable_cache: true }.merge(options)
18
+ @connection_pool = ConnectionPool.new(@options) if @options[:enable_cache]
19
+ @resolver = DualStackResolver.new(@options) end
20
+
21
+ def call(env)
22
+ puts "=== HAPPY EYEBALLS ADAPTER CALLED ===" if ENV["DEBUG_HAPPYEYES"] == "true"
23
+ puts "Original URL: #{env[:url]}" if ENV["DEBUG_HAPPYEYES"] == "true"
24
+ uri = env[:url] # Extract connection details
25
+ original_hostname = uri.hostname
26
+ port = uri.port || (uri.scheme == "https" ? 443 : 80)
27
+ puts "Resolving #{original_hostname}:#{port}" if ENV["DEBUG_HAPPYEYES"] == "true"
28
+ begin
29
+ connection_key = "#{original_hostname}:#{port}"
30
+ successful_address = @connection_pool && @connection_pool.get_connection(connection_key)
31
+ used_cached_address = !successful_address.nil?
32
+ unless successful_address
33
+ # Resolve addresses using your DualStackResolver
34
+ addresses = @resolver.resolve_dual_stack(original_hostname)
35
+ raise AllConnectionsFailed, "No addresses resolved for #{original_hostname}" if addresses.empty?
36
+ sorted_addresses = sort_addresses(addresses) # Sort addresses according to preference
37
+ successful_address = attempt_connections(sorted_addresses, port) # Attempt connections with Happy Eyeballs algorithm
38
+ @connection_pool.cache_connection(connection_key, successful_address) if @connection_pool end
39
+ begin
40
+ make_request_with_address(env, successful_address, port) # Make the actual HTTP request with proper SNI
41
+ rescue Faraday::ConnectionFailed
42
+ # Only worth a fresh race if the failure came from a stale cached address.
43
+ raise unless used_cached_address
44
+ @connection_pool.clear_connection(connection_key)
45
+ addresses = @resolver.resolve_dual_stack(original_hostname)
46
+ raise AllConnectionsFailed, "No addresses resolved for #{original_hostname}" if addresses.empty?
47
+ successful_address = attempt_connections(sort_addresses(addresses), port)
48
+ @connection_pool.cache_connection(connection_key, successful_address)
49
+ make_request_with_address(env, successful_address, port) end
50
+ rescue AllConnectionsFailed, ConnectionTimeout => e
51
+ raise Faraday::ConnectionFailed, e.message
52
+ rescue Timeout::Error
53
+ raise Faraday::TimeoutError, "Connection timeout after #{@options[:connection_timeout]}s" end end
54
+
55
+ private
56
+ def sort_addresses(addresses)
57
+ if @options[:prefer_ipv6] # IPv6 first, then IPv4
58
+ ipv6_addrs = addresses.select { |addr| addr[:family] == :ipv6 }
59
+ ipv4_addrs = addresses.select { |addr| addr[:family] == :ipv4 }
60
+ ipv6_addrs + ipv4_addrs
61
+ else # IPv4 first, then IPv6
62
+ ipv4_addrs = addresses.select { |addr| addr[:family] == :ipv4 }
63
+ ipv6_addrs = addresses.select { |addr| addr[:family] == :ipv6 }
64
+ ipv4_addrs + ipv6_addrs end end
65
+
66
+ def attempt_connections(addresses, port)
67
+ successful_address = nil
68
+ threads = []
69
+ mutex = Mutex.new
70
+
71
+ addresses.each_with_index do |address, index|
72
+ break if mutex.synchronize { successful_address }
73
+ sleep(@options[:connection_attempt_delay]) if index > 0
74
+
75
+ thread = Thread.new do
76
+ begin
77
+ puts "Testing connection: #{address[:ip]}:#{port} (Family: #{address[:family]})" if ENV["DEBUG_HAPPYEYES"] == "true"
78
+ if test_connection(address[:ip], port, address[:family])
79
+ mutex.synchronize do
80
+ if successful_address.nil?
81
+ successful_address = address
82
+ puts "Success! Using #{address[:ip]}:#{port}" if ENV["DEBUG_HAPPYEYES"] == "true"
83
+ end
84
+ end
85
+ end
86
+ rescue => e
87
+ puts "Connection attempt failed for #{address[:ip]}: #{e.message}" if ENV["DEBUG_HAPPYEYES"] == "true"
88
+ end
89
+ end
90
+
91
+ threads << thread
92
+ sleep(0.05) # Small delay to allow threads to start
93
+ end
94
+
95
+ # Wait for first success or all failures
96
+ start_time = Time.now
97
+ while successful_address.nil? && (Time.now - start_time) < @options[:connection_timeout]
98
+ sleep(0.1)
99
+ threads.reject!(&:alive?)
100
+ break if threads.empty?
101
+ end
102
+
103
+ # Clean up remaining threads
104
+ threads.each { |t| t.kill if t.alive? }
105
+
106
+ successful_address || raise(AllConnectionsFailed, "All connection attempts failed")
107
+ end
108
+
109
+ def test_connection(ip, port, family)
110
+ socket = nil
111
+ begin
112
+ domain = family == :ipv6 ? Socket::AF_INET6 : Socket::AF_INET
113
+ socket = Socket.new(domain, Socket::SOCK_STREAM, 0)
114
+ sockaddr = Socket.sockaddr_in(port, ip)
115
+
116
+ puts "Testing connection to #{ip}:#{port} (#{family})" if ENV["DEBUG_HAPPYEYES"] == "true"
117
+
118
+ # Set socket to non-blocking mode
119
+ socket.connect_nonblock(sockaddr)
120
+ true # Connection succeeded immediately
121
+ rescue IO::WaitWritable
122
+ # Connection in progress, wait for completion
123
+ puts "Connection in progress for #{ip}:#{port}" if ENV["DEBUG_HAPPYEYES"] == "true"
124
+ if IO.select(nil, [socket], nil, @options[:connection_timeout])
125
+ begin
126
+ # Check if connection completed successfully
127
+ socket.connect_nonblock(sockaddr)
128
+ true
129
+ rescue Errno::EISCONN
130
+ # Already connected - success
131
+ puts "Connection successful to #{ip}:#{port}" if ENV["DEBUG_HAPPYEYES"] == "true"
132
+ true
133
+ rescue Errno::ECONNREFUSED, Errno::EHOSTUNREACH, Errno::ENETUNREACH => e
134
+ puts "Connection failed to #{ip}:#{port}: #{e.class}" if ENV["DEBUG_HAPPYEYES"] == "true"
135
+ false end
136
+ else
137
+ puts "Connection timeout to #{ip}:#{port}" if ENV["DEBUG_HAPPYEYES"] == "true"
138
+ false end
139
+ rescue Errno::EISCONN
140
+ # Already connected
141
+ puts "Already connected to #{ip}:#{port}" if ENV["DEBUG_HAPPYEYES"] == "true"
142
+ true
143
+ rescue Errno::ECONNREFUSED, Errno::EHOSTUNREACH, Errno::ENETUNREACH => e
144
+ puts "Connection refused to #{ip}:#{port}: #{e.class}" if ENV["DEBUG_HAPPYEYES"] == "true"
145
+ false
146
+ rescue => e
147
+ puts "Unexpected error connecting to #{ip}:#{port}: #{e.class} - #{e.message}" if ENV["DEBUG_HAPPYEYES"] == "true"
148
+ false
149
+ ensure
150
+ socket&.close end end
151
+
152
+ def make_request_with_address(env, address, port)
153
+ original_hostname = env[:url].hostname
154
+ puts "Making direct Net::HTTP request to #{address[:ip]} with SNI #{original_hostname}" if ENV["DEBUG_HAPPYEYES"] == "true"
155
+ begin
156
+ # Keep the hostname for SNI and certificate verification while dialing the
157
+ # address selected by Happy Eyeballs.
158
+ http = Net::HTTP.new(original_hostname, port)
159
+ http.ipaddr = address[:ip]
160
+ request_timeout = @options[:connection_timeout]
161
+ http.open_timeout = request_timeout
162
+ http.read_timeout = request_timeout
163
+ http.write_timeout = request_timeout if http.respond_to?(:write_timeout=)
164
+ request = build_net_http_request(env, original_hostname)
165
+
166
+ if env[:url].scheme == "https"
167
+ configure_ssl(http, env)
168
+ response = http.start { |http_conn| http_conn.request(request) }
169
+ else
170
+ response = http.request(request)
171
+ end
172
+
173
+ puts "Response received: #{response.code} #{response.message}" if ENV["DEBUG_HAPPYEYES"] == "true"
174
+ build_faraday_response(env, response)
175
+
176
+ rescue => e
177
+ puts "Direct Net::HTTP request failed: #{e.class} - #{e.message}" if ENV["DEBUG_HAPPYEYES"] == "true"
178
+ puts e.backtrace.first(5) if ENV["DEBUG_HAPPYEYES"] == "true"
179
+ raise Faraday::ConnectionFailed, e.message end end
180
+
181
+ def configure_ssl(http, env)
182
+ http.use_ssl = true
183
+ http.verify_mode = OpenSSL::SSL::VERIFY_PEER
184
+ ca_file = @options[:ca_file] || env.dig(:ssl, :ca_file) || ENV["SSL_CERT_FILE"] || ENV["SSL_CA_FILE"] ||
185
+ ["/etc/ssl/certs/ca-certificates.crt", OpenSSL::X509::DEFAULT_CERT_FILE].find { |path| File.file?(path) }
186
+ http.ca_file = ca_file if ca_file
187
+ puts "ca_file => #{ca_file}" if ENV["DEBUG_HAPPYEYES"] == "true" end
188
+
189
+ def build_net_http_request(env, hostname)
190
+ request_class = NET_HTTP_REQUEST_CLASSES.fetch(env[:method].to_s.upcase, Net::HTTP::Get)
191
+ request = request_class.new(env[:url].request_uri)
192
+ request["Host"] = hostname
193
+
194
+ (env[:request_headers] || {}).each { |key, value| request[key] = value }
195
+
196
+ if env[:body] && request.respond_to?(:body=)
197
+ request.body = env[:body]
198
+ request["Content-Length"] = env[:body].bytesize.to_s
199
+ end
200
+ request end
201
+
202
+ def build_faraday_response(env, response)
203
+ faraday_headers = Faraday::Utils::Headers.new
204
+ response.each_header { |key, value| faraday_headers[key] = value }
205
+
206
+ env[:status] = response.code.to_i
207
+ env[:response_headers] = faraday_headers
208
+ env[:body] = response.body
209
+ Faraday::Response.new(env) end
210
+
211
+ def determine_default_adapter # Return Net::HTTP adapter as default, or whatever Faraday's default is
212
+ Faraday::Adapter::NetHttp end end end end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+ require "socket"
3
+ module Faraday
4
+ module HappyEyeballs
5
+ class ConnectionPool
6
+ def initialize(options = {})
7
+ @options = options
8
+ @connections = {}
9
+ @connection_mutex = Mutex.new end
10
+
11
+ def has_connection?(connection_key)
12
+ @connection_mutex.synchronize do
13
+ cached = @connections[connection_key]
14
+ return false unless cached
15
+ # Check if cached connection is still valid
16
+ if Time.now - cached[:timestamp] > @options.fetch(:cache_ttl, 300)
17
+ @connections.delete(connection_key)
18
+ return false end
19
+ # Optionally test if the connection is still alive
20
+ if @options.fetch(:test_cached_connections, false) then return test_connection_alive(cached[:address]) end
21
+ true end end
22
+
23
+ def cache_connection(connection_key, address)
24
+ @connection_mutex.synchronize do
25
+ @connections[connection_key] = {
26
+ address: address,
27
+ timestamp: Time.now } end end
28
+
29
+ def get_connection(connection_key)
30
+ @connection_mutex.synchronize do
31
+ cached = @connections[connection_key]
32
+ return nil unless cached
33
+ # Check TTL
34
+ if Time.now - cached[:timestamp] > @options.fetch(:cache_ttl, 300)
35
+ @connections.delete(connection_key)
36
+ return nil end
37
+ cached[:address] end end
38
+
39
+ def clear_connection(connection_key) @connection_mutex.synchronize do @connections.delete(connection_key) end end
40
+ def clear_all_connections() @connection_mutex.synchronize do @connections.clear end end
41
+ def connection_count() @connection_mutex.synchronize do @connections.size end end
42
+ def cleanup_expired_connections # Clean up expired connections
43
+ current_time = Time.now
44
+ ttl = @options.fetch(:cache_ttl, 300)
45
+ @connection_mutex.synchronize do
46
+ @connections.reject! do |_key, cached| current_time - cached[:timestamp] > ttl end end end
47
+
48
+ private
49
+ def test_connection_alive(address)
50
+ # This is a lightweight test to see if we can still connect
51
+ # to the cached address. In a real implementation, you might
52
+ # want to keep actual socket connections alive.
53
+ begin
54
+ socket = nil
55
+ if address[:family] == :ipv6 then socket = Socket.new(Socket::AF_INET6, Socket::SOCK_STREAM, 0)
56
+ else socket = Socket.new(Socket::AF_INET, Socket::SOCK_STREAM, 0) end
57
+ # Quick non-blocking connection test
58
+ sockaddr = Socket.sockaddr_in(80, address[:ip]) # Default to port 80 for test
59
+ begin
60
+ socket.connect_nonblock(sockaddr)
61
+ rescue IO::WaitWritable, Errno::EISCONN
62
+ # Connection in progress, or already connected - either is good enough for our test
63
+ socket.close
64
+ return true
65
+ rescue
66
+ # Connection failed
67
+ socket.close
68
+ return false end
69
+ socket.close
70
+ true
71
+ rescue
72
+ false end end end end end
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+ require "resolv"
3
+ require "timeout"
4
+ require "socket"
5
+ module Faraday
6
+ module HappyEyeballs
7
+ class DualStackResolver
8
+ def initialize(options = {})
9
+ @options = options
10
+ @resolution_cache = {}
11
+ @cache_mutex = Mutex.new end
12
+
13
+ def resolve_dual_stack(hostname)
14
+ if @options.fetch(:enable_cache, true)
15
+ cached = cached_addresses(hostname)
16
+ return cached if cached end
17
+ addresses = perform_dual_stack_resolution(hostname)
18
+ addresses = direct_resolution(hostname) if addresses.empty?
19
+ if @options.fetch(:enable_cache, true) && !addresses.empty?
20
+ cache_addresses(hostname, addresses) end
21
+ addresses end
22
+
23
+ private
24
+ def cached_addresses(hostname)
25
+ @cache_mutex.synchronize do
26
+ entry = @resolution_cache[hostname]
27
+ return nil unless entry
28
+ if Time.now - entry[:timestamp] > @options.fetch(:cache_ttl, 300)
29
+ @resolution_cache.delete(hostname)
30
+ return nil end
31
+ entry[:addresses] end end
32
+
33
+ def cache_addresses(hostname, addresses)
34
+ @cache_mutex.synchronize do
35
+ @resolution_cache[hostname] = { addresses: addresses, timestamp: Time.now } end end
36
+ def perform_dual_stack_resolution(hostname)
37
+ # Resolve both families concurrently (each bounded by its own :dns_timeout
38
+ # inside resolve_ipv6/resolve_ipv4). We must not discard a resolved family
39
+ # here: DNS answering for one family says nothing about whether that
40
+ # family is actually *routable* from this host (e.g. AAAA records exist
41
+ # but the host has no IPv6 route) - that determination belongs to the
42
+ # connection race in attempt_connections, not to DNS resolution.
43
+ ipv6_thread = Thread.new { resolve_ipv6(hostname) } # Resolution threads
44
+ sleep(@options.fetch(:resolution_delay, 0.05)) # Small head start for the preferred family
45
+ ipv4_thread = Thread.new { resolve_ipv4(hostname) }
46
+ ipv6_addresses = begin ipv6_thread.value || [] rescue []; end
47
+ ipv4_addresses = begin ipv4_thread.value || [] rescue []; end
48
+ format_addresses(ipv6_addresses, ipv4_addresses, hostname) end
49
+
50
+ def direct_resolution(hostname) # Last resort: try to resolve directly with Ruby's Socket
51
+ begin
52
+ addrs = Socket.getaddrinfo(hostname, nil)
53
+ results = []
54
+ addrs.each do |addr|
55
+ family = (addr[0] == "AF_INET6") ? :ipv6 : :ipv4
56
+ results << { ip: addr[3], family: family, hostname: hostname } end
57
+ results
58
+ rescue
59
+ [] end end # All resolution methods failed
60
+
61
+ def format_addresses(ipv6_addresses, ipv4_addresses, hostname)
62
+ all_addresses = [] # Format IPv6, IPv4 addresses
63
+ ipv6_addresses.each do |addr| all_addresses << { ip: addr.to_s, family: :ipv6, hostname: hostname } end
64
+ ipv4_addresses.each do |addr| all_addresses << { ip: addr.to_s, family: :ipv4, hostname: hostname } end
65
+ all_addresses end
66
+
67
+ def resolve_ipv6(hostname)
68
+ catch(:resolution_done) do
69
+ begin
70
+ Timeout::timeout(@options.fetch(:dns_timeout, 5)) do
71
+ dns = Resolv::DNS.new
72
+ results = dns.getresources(hostname, Resolv::DNS::Resource::IN::AAAA).map(&:address)
73
+ throw :resolution_done, results end
74
+ rescue Resolv::ResolvError, Timeout::Error
75
+ throw :resolution_done, [] end
76
+ [] end end # Default empty result if we somehow get here
77
+
78
+ def resolve_ipv4(hostname)
79
+ catch(:resolution_done) do
80
+ begin
81
+ Timeout::timeout(@options.fetch(:dns_timeout, 5)) do
82
+ dns = Resolv::DNS.new
83
+ results = dns.getresources(hostname, Resolv::DNS::Resource::IN::A).map(&:address)
84
+ throw :resolution_done, results end
85
+ rescue Resolv::ResolvError, Timeout::Error
86
+ throw :resolution_done, [] end
87
+ [] end end end end end # Default empty result if we somehow get here
@@ -0,0 +1,4 @@
1
+ # frozen_string_literal: true
2
+ module Faraday
3
+ module HappyEyeballs
4
+ VERSION = "0.3.0" end end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+ require "faraday"
3
+ require "socket"
4
+ require "timeout"
5
+ require "resolv"
6
+ require_relative "happy_eyeballs/version"
7
+ require_relative "happy_eyeballs/adapter"
8
+ require_relative "happy_eyeballs/connection_pool"
9
+ require_relative "happy_eyeballs/dual_stack_resolver"
10
+ module Faraday
11
+ module HappyEyeballs
12
+ class Error < StandardError; end
13
+ class ConnectionTimeout < Error; end
14
+ class AllConnectionsFailed < Error; end
15
+
16
+ # Register the adapter with Faraday
17
+ Faraday::Adapter.register_middleware( happy_eyeballs: -> { Faraday::HappyEyeballs::Adapter }) end end
metadata ADDED
@@ -0,0 +1,73 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: faraday-happy-eyeballs
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.3.0
5
+ platform: ruby
6
+ authors:
7
+ - Claude + MarkT
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: faraday
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '1.0'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: '1.0'
26
+ description: Implements Happy Eyeballs algorithm as a Faraday adapter, providing automatic
27
+ IPv4/IPv6 fallback and dual-stack connection racing
28
+ email:
29
+ - paradisaeidae@gmail.com
30
+ executables: []
31
+ extensions: []
32
+ extra_rdoc_files: []
33
+ files:
34
+ - ".rspec"
35
+ - ".rubocop.yml"
36
+ - CHANGELOG.md
37
+ - Gemfile
38
+ - LICENSE.txt
39
+ - README.md
40
+ - Rakefile
41
+ - example_usage.rb
42
+ - lib/faraday/happy_eyeballs.rb
43
+ - lib/faraday/happy_eyeballs/adapter.rb
44
+ - lib/faraday/happy_eyeballs/connection_pool.rb
45
+ - lib/faraday/happy_eyeballs/dual_stack_resolver.rb
46
+ - lib/faraday/happy_eyeballs/version.rb
47
+ homepage: https://github.com/paradisaeidae/happy_eye_balls
48
+ licenses:
49
+ - MIT
50
+ metadata:
51
+ allowed_push_host: https://rubygems.org
52
+ homepage_uri: https://github.com/paradisaeidae/happy_eye_balls
53
+ source_code_uri: https://github.com/paradisaeidae/happy_eye_balls
54
+ changelog_uri: https://github.com/paradisaeidae/happy_eye_balls/blob/main/CHANGELOG.md
55
+ rubygems_mfa_required: 'true'
56
+ rdoc_options: []
57
+ require_paths:
58
+ - lib
59
+ required_ruby_version: !ruby/object:Gem::Requirement
60
+ requirements:
61
+ - - ">="
62
+ - !ruby/object:Gem::Version
63
+ version: 2.7.0
64
+ required_rubygems_version: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - ">="
67
+ - !ruby/object:Gem::Version
68
+ version: '0'
69
+ requirements: []
70
+ rubygems_version: 4.0.18
71
+ specification_version: 4
72
+ summary: Happy Eyeballs (RFC 6555) adapter for Faraday HTTP client
73
+ test_files: []