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 +7 -0
- data/.rspec +3 -0
- data/.rubocop.yml +130 -0
- data/CHANGELOG.md +73 -0
- data/Gemfile +12 -0
- data/LICENSE.txt +21 -0
- data/README.md +203 -0
- data/Rakefile +15 -0
- data/example_usage.rb +103 -0
- data/lib/faraday/happy_eyeballs/adapter.rb +212 -0
- data/lib/faraday/happy_eyeballs/connection_pool.rb +72 -0
- data/lib/faraday/happy_eyeballs/dual_stack_resolver.rb +87 -0
- data/lib/faraday/happy_eyeballs/version.rb +4 -0
- data/lib/faraday/happy_eyeballs.rb +17 -0
- metadata +73 -0
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
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
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,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: []
|