├── .github └── workflows │ └── ci.yml ├── .gitignore ├── .rubocop.yml ├── Gemfile ├── LICENSE.txt ├── README.md ├── Rakefile ├── TESTING.md ├── lib ├── net │ ├── tcp_client.rb │ └── tcp_client │ │ ├── address.rb │ │ ├── exceptions.rb │ │ ├── policy │ │ ├── base.rb │ │ ├── custom.rb │ │ ├── ordered.rb │ │ └── random.rb │ │ ├── tcp_client.rb │ │ └── version.rb └── net_tcp_client.rb ├── net_tcp_client.gemspec └── test ├── address_test.rb ├── policy ├── custom_policy_test.rb ├── ordered_policy_test.rb └── random_policy_test.rb ├── simple_tcp_server.rb ├── ssl_files ├── ca.key ├── ca.pem ├── localhost-server-key.pem ├── localhost-server.pem ├── no-sni-key.pem └── no-sni.pem ├── tcp_client_test.rb └── test_helper.rb /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: build 2 | 3 | on: 4 | - push 5 | - pull_request 6 | 7 | jobs: 8 | test: 9 | name: "Test: Ruby ${{ matrix.ruby }}" 10 | runs-on: ubuntu-latest 11 | strategy: 12 | fail-fast: false 13 | matrix: 14 | ruby: [2.7, "3.0", 3.1, 3.2] 15 | steps: 16 | - uses: actions/checkout@v2 17 | - name: Set up Ruby 18 | uses: ruby/setup-ruby@v1 19 | with: 20 | ruby-version: ${{ matrix.ruby }} 21 | # runs 'bundle install' and caches installed gems automatically 22 | bundler-cache: true 23 | - name: Ruby Version 24 | run: ruby --version 25 | - name: Run Tests 26 | run: bundle exec rake 27 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | *.gem 3 | .idea/* 4 | *.log 5 | .rakeTasks 6 | Gemfile.lock 7 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | AllCops: 2 | TargetRubyVersion: 2.7 3 | SuggestExtensions: false 4 | NewCops: enable 5 | Exclude: 6 | - ".git/**/*" 7 | - "docs/**/*" 8 | 9 | # 10 | # RuboCop built-in settings. 11 | # For documentation on all settings see: https://docs.rubocop.org/en/stable 12 | # 13 | 14 | # Trailing periods. 15 | Layout/DotPosition: 16 | EnforcedStyle: trailing 17 | 18 | # Turn on auto-correction of equals alignment. 19 | Layout/EndAlignment: 20 | AutoCorrect: true 21 | 22 | # Prevent accidental windows line endings 23 | Layout/EndOfLine: 24 | EnforcedStyle: lf 25 | 26 | # Use a table layout for hashes 27 | Layout/HashAlignment: 28 | EnforcedHashRocketStyle: table 29 | EnforcedColonStyle: table 30 | 31 | # Soften limits 32 | Layout/LineLength: 33 | Max: 128 34 | Exclude: 35 | - "**/test/**/*" 36 | 37 | # Match existing layout 38 | Layout/SpaceInsideHashLiteralBraces: 39 | EnforcedStyle: no_space 40 | 41 | # TODO: Soften Limits for phase 1 only 42 | Metrics/AbcSize: 43 | Max: 40 44 | 45 | # Support long block lengths for tests 46 | Metrics/BlockLength: 47 | Exclude: 48 | - "test/**/*" 49 | - "**/*/cli.rb" 50 | AllowedMethods: 51 | - "aasm" 52 | - "included" 53 | 54 | # Soften limits 55 | Metrics/ClassLength: 56 | Max: 250 57 | Exclude: 58 | - "test/**/*" 59 | 60 | # TODO: Soften Limits for phase 1 only 61 | Metrics/CyclomaticComplexity: 62 | Max: 15 63 | 64 | # Soften limits 65 | Metrics/MethodLength: 66 | Max: 50 67 | 68 | # Soften limits 69 | Metrics/ModuleLength: 70 | Max: 250 71 | 72 | Metrics/ParameterLists: 73 | CountKeywordArgs: false 74 | 75 | # TODO: Soften Limits for phase 1 only 76 | Metrics/PerceivedComplexity: 77 | Max: 21 78 | 79 | # Initialization Vector abbreviation 80 | Naming/MethodParameterName: 81 | AllowedNames: ['iv', '_', 'io', 'ap'] 82 | 83 | # Does not allow Symbols to load 84 | Security/YAMLLoad: 85 | AutoCorrect: false 86 | 87 | # Needed for testing DateTime 88 | Style/DateTime: 89 | Exclude: ["test/**/*"] 90 | 91 | # TODO: Soften Limits for phase 1 only 92 | Style/Documentation: 93 | Enabled: false 94 | 95 | # One line methods 96 | Style/EmptyMethod: 97 | EnforcedStyle: expanded 98 | 99 | # Ruby 3 compatibility feature 100 | Style/FrozenStringLiteralComment: 101 | Enabled: false 102 | 103 | Style/NumericPredicate: 104 | AutoCorrect: true 105 | 106 | # Incorrectly changes job.fail to job.raise 107 | Style/SignalException: 108 | Enabled: false 109 | 110 | # Since English may not be loaded, cannot force its use. 111 | Style/SpecialGlobalVars: 112 | Enabled: false 113 | 114 | # Make it easier for developers to move between Elixir and Ruby. 115 | Style/StringLiterals: 116 | EnforcedStyle: double_quotes 117 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | gemspec 4 | 5 | gem "amazing_print" 6 | gem "bson" 7 | gem "minitest" 8 | gem "minitest-reporters" 9 | gem "rake" 10 | gem "rubocop" 11 | gem "semantic_logger" 12 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright 2012, 2013, 2014 Reid Morrison 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # net_tcp_client 2 | [![Gem Version](https://img.shields.io/gem/v/net_tcp_client.svg)](https://rubygems.org/gems/net_tcp_client) [![Build Status](https://github.com/reidmorrison/net_tcp_client/workflows/build/badge.svg)](https://github.com/reidmorrison/net_tcp_client/actions?query=workflow%3Abuild) [![Downloads](https://img.shields.io/gem/dt/net_tcp_client.svg)](https://rubygems.org/gems/net_tcp_client) [![License](https://img.shields.io/badge/license-Apache%202.0-brightgreen.svg)](http://opensource.org/licenses/Apache-2.0) ![](https://img.shields.io/badge/status-Production%20Ready-blue.svg) 3 | 4 | Net::TCPClient is a TCP Socket Client with automated failover, load balancing, retries and built-in timeouts. 5 | 6 | * https://github.com/reidmorrison/net_tcp_client 7 | 8 | ## Introduction 9 | 10 | Net::TCPClient implements high availability and resilience features that many developers wish was 11 | already included in the standard Ruby libraries. 12 | 13 | Another important feature is that the _connect_ and _read_ API's use timeout's to 14 | prevent a network issue from "hanging" the client program. 15 | 16 | ## Features 17 | 18 | * Automated failover to another server. 19 | * Load balancing across multiple servers. 20 | * SSL and non-ssl connections. 21 | * Connect Timeout. 22 | * Read Timeout. 23 | * Write Timeout. 24 | * Fails over / load balances across all servers under a single DNS entry. 25 | * Logging. 26 | * Optional trace level logging of all data sent or received. 27 | * Uses non blocking timeouts, instead of using threads such as used by the Timeout class. 28 | * Additional exceptions to distinguish between connection failures and timeouts. 29 | * Handshake callbacks. 30 | * After a new connection has been established callbacks can be used 31 | for handshakes such as authentication before data is sent. 32 | 33 | ### Example 34 | 35 | ~~~ruby 36 | require 'net/tcp_client' 37 | 38 | Net::TCPClient.connect(server: 'mydomain:3300') do |client| 39 | client.write('Update the database') 40 | response = client.read(20) 41 | puts "Received: #{response}" 42 | end 43 | ~~~ 44 | 45 | Enable SSL encryption: 46 | 47 | ~~~ruby 48 | require 'net/tcp_client' 49 | 50 | Net::TCPClient.connect(server: 'mydomain:3300', ssl: true) do |client| 51 | client.write('Update the database') 52 | response = client.read(20) 53 | puts "Received: #{response}" 54 | end 55 | ~~~ 56 | 57 | ## High Availability 58 | 59 | Net::TCPClient automatically tries each server in turn, should it fail to connect, or 60 | if the connection is lost the next server is tried immediately. 61 | 62 | Net::TCPClient detects DNS entries that have multiple IP Addresses associated with them and 63 | adds each of the ip addresses for the single DNS name to the list of servers to try to connect to. 64 | 65 | If a server is unavailable, cannot connect, or the connection is lost, the next server is immediately 66 | tried. Once all servers have been exhausted, it will keep trying to connect, starting with the 67 | first server again. 68 | 69 | When a connection is first established, and every time a connection is lost, Net::TCPClient 70 | uses connection policies to determine which server to connect to. 71 | 72 | ## Load Balancing 73 | 74 | Using the connection policies client TCP connections can be balanced across multiple servers. 75 | 76 | ## Connection Policies 77 | 78 | #### Ordered 79 | 80 | Servers are tried in the order they were supplied. 81 | 82 | ~~~ruby 83 | tcp_client = Net::TCPClient.new( 84 | servers: ['server1:3300', 'server2:3300', 'server3:3600'] 85 | ) 86 | ~~~ 87 | 88 | The servers will tried in the following order: 89 | `server1`, `server2`, `server3` 90 | 91 | `:ordered` is the default, but can be explicitly defined follows: 92 | 93 | ~~~ruby 94 | tcp_client = Net::TCPClient.new( 95 | servers: ['server1:3300', 'server2:3300', 'server3:3600'], 96 | policy: :ordered 97 | ) 98 | ~~~ 99 | 100 | #### Random 101 | 102 | Servers are tried in a Random order. 103 | 104 | ~~~ruby 105 | tcp_client = Net::TCPClient.new( 106 | servers: ['server1:3300', 'server2:3300', 'server3:3600'], 107 | policy: :random 108 | ) 109 | ~~~ 110 | 111 | No server is tried again until all of the others have been tried first. 112 | 113 | Example run, the servers could be tried in the following order: 114 | `server3`, `server1`, `server2` 115 | 116 | #### Custom defined order 117 | 118 | Supply your own custom order / load balancing algorithm for connecting to servers: 119 | 120 | Example: 121 | 122 | ~~~ruby 123 | tcp_client = Net::TCPClient.new( 124 | servers: ['server1:3300', 'server2:3300', 'server3:3600'], 125 | policy: -> addresses, count do 126 | # Return nil after the last address has been tried so that retry logic can take over 127 | if count <= address.size 128 | addresses.sample 129 | end 130 | end 131 | ) 132 | ~~~ 133 | 134 | The above example returns addresses in random order without checking if a host name has been used before. 135 | 136 | It is important to check the count so that once all servers have been tried, it should return nil so that 137 | the retry logic can take over. Otherwise it will constantly try to connect to the servers without 138 | the retry delays etc. 139 | 140 | Example run, the servers could be tried in the following order: 141 | `server3`, `server1`, `server3` 142 | 143 | ### Automatic Retry 144 | 145 | If a connection cannot be established to any servers in the list Net::TCPClient will retry from the 146 | first server. This retry behavior can be controlled using the following options: 147 | 148 | * `connect_retry_count` [Integer] 149 | * Number of times to retry connecting when a connection fails 150 | * Default: 10 151 | 152 | * `connect_retry_interval` [Float] 153 | * Number of seconds between connection retry attempts after the first failed attempt 154 | * Default: 0.5 155 | 156 | * `retry_count` [Integer] 157 | * Number of times to retry when calling #retry_on_connection_failure 158 | * This is independent of :connect_retry_count which still applies with 159 | * connection failures. This retry controls upto how many times to retry the 160 | * supplied block should a connection failure occur during the block 161 | * Default: 3 162 | 163 | #### Note 164 | 165 | A server will only be retried again using the retry controls above once all other servers in the 166 | list have been exhausted. 167 | 168 | This means that if a connection is lost to a server that it will try to connect to a different server, 169 | not the same server unless it is the only server in the list. 170 | 171 | ## Tuning 172 | 173 | If there are multiple servers in the list it is important to keep the `connect_timeout` low otherwise 174 | it can take a long time to find the next available server. 175 | 176 | ## Retry on connection loss 177 | 178 | To transparently handle when a connection is lost after it has been established 179 | wrap calls that can be retried with `retry_on_connection_failure`. 180 | 181 | ~~~ruby 182 | Net::TCPClient.connect( 183 | server: 'localhost:3300', 184 | connect_retry_interval: 0.1, 185 | connect_retry_count: 5 186 | ) do |client| 187 | # If the connection is lost, create a new one and retry the write 188 | client.retry_on_connection_failure do 189 | client.write('How many users available?') 190 | response = client.read(20) 191 | puts "Received: #{response}" 192 | end 193 | end 194 | ~~~ 195 | 196 | If the connection is lost during either the `write` or the `read` above the 197 | entire block will be re-tried once the connection has been re-stablished. 198 | 199 | ## Callbacks 200 | 201 | Any time a connection has been established a callback can be called to handle activities such as: 202 | 203 | * Initialize per connection session sequence numbers. 204 | * Pass authentication information to the server. 205 | * Perform a handshake with the server. 206 | 207 | #### Authentication example: 208 | 209 | ~~~ruby 210 | tcp_client = Net::TCPClient.new( 211 | servers: ['server1:3300', 'server2:3300', 'server3:3600'], 212 | on_connect: -> do |client| 213 | client.write('My username and password') 214 | result = client.read(2) 215 | raise "Authentication failed" if result != 'OK' 216 | end 217 | ) 218 | ~~~ 219 | 220 | #### Per connection sequence number example: 221 | 222 | ~~~ruby 223 | tcp_client = Net::TCPClient.new( 224 | servers: ['server1:3300', 'server2:3300', 'server3:3600'], 225 | on_connect: -> do |client| 226 | # Set the sequence number to 0 227 | client.user_data = 0 228 | end 229 | ) 230 | 231 | tcp_client.retry_on_connection_failure do 232 | # Write with the sequence number 233 | tcp_client.write("#{tcp_client.user_data} hello") 234 | result = tcp_client.receive(30) 235 | 236 | # Increment sequence number after every call to the server 237 | tcp_client.user_data += 1 238 | end 239 | ~~~ 240 | 241 | ## Project Status 242 | 243 | ### Production Ready 244 | 245 | Net::TCPClient is actively being used in a high performance, highly concurrent 246 | production environments. The resilient capabilities of Net::TCPClient are put to the 247 | test on a daily basis, including connections over the internet between remote data centers. 248 | 249 | ## Installation 250 | 251 | gem install net_tcp_client 252 | 253 | To enable logging add [Semantic Logger](https://logger.rocketjob.io/): 254 | 255 | gem install semantic_logger 256 | 257 | Or, add the following lines to you `Gemfile`: 258 | 259 | ~~~ruby 260 | gem 'semantic_logger' 261 | gem 'net_tcp_client' 262 | ~~~ 263 | 264 | To configure a stand-alone application for Semantic Logger: 265 | 266 | ~~~ruby 267 | require 'semantic_logger' 268 | 269 | # Set the global default log level 270 | SemanticLogger.default_level = :trace 271 | 272 | # Log to a file, and use the colorized formatter 273 | SemanticLogger.add_appender(file_name: 'development.log', formatter: :color) 274 | ~~~ 275 | 276 | If running Rails, see: [Semantic Logger Rails](https://logger.rocketjob.io/rails.html) 277 | 278 | ### Support 279 | 280 | Join the [Gitter chat session](https://gitter.im/rocketjob/support) if you have any questions. 281 | 282 | Issues / bugs can be reported via [Github issues](https://github.com/reidmorrison/net_tcp_client/issues). 283 | 284 | ### Upgrading to V2 285 | 286 | The following breaking changes have been made with V2: 287 | * The Connection timeout default is now 10 seconds, was 30 seconds. 288 | * To enable logging, add gem semantic_logger. 289 | * The :logger option has been removed. 290 | * Deprecated option and attribute :server_selector has been removed. 291 | 292 | ### Upgrading from ResilientSocket ![](https://img.shields.io/gem/dt/resilient_socket.svg) 293 | 294 | ResilientSocket::TCPClient has been renamed to Net::TCPClient. 295 | The API is exactly the same, just with a new namespace. Please upgrade to the new 296 | `net_tcp_client` gem and replace all occurrences of `ResilientSocket::TCPClient` 297 | with `Net::TCPClient` in your code. 298 | 299 | ## Supports 300 | 301 | Tested and supported on the following Ruby platforms: 302 | - Ruby 2.1, 2.2, 2.3 and above 303 | - JRuby 1.7.23, 9.0 and above 304 | - Rubinius 2.5 and above 305 | 306 | There is a soft dependency on [Semantic Logger](https://github.com/reidmorrison/semantic_logger). It will use SemanticLogger only if 307 | it is already available, otherwise any other standard Ruby logger can be used. 308 | 309 | ### Note 310 | 311 | Be sure to place the `semantic_logger` gem dependency before `net_tcp_client` in your Gemfile. 312 | 313 | ## Author 314 | 315 | [Reid Morrison](https://github.com/reidmorrison) 316 | 317 | [Contributors](https://github.com/reidmorrison/net_tcp_client/graphs/contributors) 318 | 319 | ## Versioning 320 | 321 | This project uses [Semantic Versioning](https://semver.org/). 322 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require "rake/testtask" 2 | require_relative "lib/net/tcp_client/version" 3 | 4 | task :gem do 5 | system "gem build net_tcp_client.gemspec" 6 | end 7 | 8 | task publish: :gem do 9 | system "git tag -a v#{Net::TCPClient::VERSION} -m 'Tagging #{Net::TCPClient::VERSION}'" 10 | system "git push --tags" 11 | system "gem push net_tcp_client-#{Net::TCPClient::VERSION}.gem" 12 | system "rm net_tcp_client-#{Net::TCPClient::VERSION}.gem" 13 | end 14 | 15 | Rake::TestTask.new(:test) do |t| 16 | t.pattern = "test/**/*_test.rb" 17 | t.verbose = true 18 | t.warning = true 19 | end 20 | 21 | task default: :test 22 | -------------------------------------------------------------------------------- /TESTING.md: -------------------------------------------------------------------------------- 1 | ## Installation 2 | 3 | Install all needed gems to run the tests: 4 | 5 | bundle update 6 | 7 | ## Run Tests 8 | 9 | Run the tests: 10 | 11 | bundle exec rake 12 | 13 | ## Linux Testing 14 | 15 | To perform Linux testing, for example when Travis fails, use docker to create run Linux locally: 16 | 17 | docker pull ruby 18 | 19 | From the directory containing the source code run the following docker command: 20 | 21 | docker run -it --rm --volume `pwd`:/src ruby bash 22 | 23 | Docker should open a shell into which the following commands can be run: 24 | 25 | cd /src 26 | gem install bundler 27 | bundle update 28 | rake 29 | 30 | ## Older Linux Testing 31 | 32 | To test using an older Alpine Linux distribution 33 | 34 | docker pull 2.3-alpine3.7 35 | 36 | From the directory containing the source code run the following docker command: 37 | 38 | docker run -it --rm --volume `pwd`:/src 2.3-alpine3.7 sh 39 | 40 | Docker should open a shell into which the following commands can be run: 41 | 42 | apk add alpine-sdk 43 | cd /src 44 | gem install bundler 45 | bundle update 46 | rake 47 | -------------------------------------------------------------------------------- /lib/net/tcp_client.rb: -------------------------------------------------------------------------------- 1 | require "socket" 2 | # Load SemanticLogger if available 3 | begin 4 | require "semantic_logger" 5 | rescue LoadError 6 | end 7 | require "net/tcp_client/version" 8 | require "net/tcp_client/address" 9 | require "net/tcp_client/exceptions" 10 | require "net/tcp_client/tcp_client" 11 | 12 | # @formatter:off 13 | module Net 14 | class TCPClient 15 | module Policy 16 | autoload :Base, "net/tcp_client/policy/base.rb" 17 | autoload :Custom, "net/tcp_client/policy/custom.rb" 18 | autoload :Ordered, "net/tcp_client/policy/ordered.rb" 19 | autoload :Random, "net/tcp_client/policy/random.rb" 20 | end 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /lib/net/tcp_client/address.rb: -------------------------------------------------------------------------------- 1 | require "socket" 2 | require "ipaddr" 3 | module Net 4 | class TCPClient 5 | # Host name, ip address and port to connect to 6 | class Address 7 | attr_accessor :host_name, :ip_address, :port 8 | 9 | # Returns [Array] ip addresses for the supplied DNS entry 10 | # Returns dns_name if it is already an IP Address 11 | def self.ip_addresses(dns_name) 12 | ips = [] 13 | Socket.getaddrinfo(dns_name, nil, Socket::AF_INET, Socket::SOCK_STREAM).each do |s| 14 | ips << s[3] if s[0] == "AF_INET" 15 | end 16 | ips.uniq 17 | end 18 | 19 | # Returns [Array] addresses for a given DNS / host name. 20 | # The Addresses will contain the resolved ip address, host name, and port number. 21 | # 22 | # Note: 23 | # Multiple ip addresses will be returned when a DNS entry has multiple ip addresses associated with it. 24 | def self.addresses(dns_name, port) 25 | ip_addresses(dns_name).collect { |ip| new(dns_name, ip, port) } 26 | end 27 | 28 | # Returns [Array] addresses for a list of DNS / host name's 29 | # that are paired with their numbers 30 | # 31 | # server_name should be either a host_name, or ip address combined with a port: 32 | # "host_name:1234" 33 | # "192.168.1.10:80" 34 | def self.addresses_for_server_name(server_name) 35 | dns_name, port = server_name.split(":") 36 | port = port.to_i 37 | unless dns_name && port&.positive? 38 | raise( 39 | ArgumentError, 40 | "Invalid host_name: #{server_name.inspect}. Must be formatted as 'host_name:1234' or '192.168.1.10:80'" 41 | ) 42 | end 43 | 44 | addresses(dns_name, port) 45 | end 46 | 47 | def initialize(host_name, ip_address, port) 48 | @host_name = host_name 49 | @ip_address = ip_address 50 | @port = port.to_i 51 | end 52 | 53 | def to_s 54 | "#{host_name}[#{ip_address}]:#{port}" 55 | end 56 | end 57 | end 58 | end 59 | -------------------------------------------------------------------------------- /lib/net/tcp_client/exceptions.rb: -------------------------------------------------------------------------------- 1 | module Net 2 | class TCPClient 3 | class ConnectionTimeout < ::SocketError 4 | end 5 | 6 | class ReadTimeout < ::SocketError 7 | end 8 | 9 | class WriteTimeout < ::SocketError 10 | end 11 | 12 | # Raised by ResilientSocket whenever a Socket connection failure has occurred 13 | class ConnectionFailure < ::SocketError 14 | # Returns the host name and port against which the connection failure occurred 15 | attr_reader :server 16 | 17 | # Returns the original exception that caused the connection failure 18 | # For example instances of Errno::ECONNRESET 19 | attr_reader :cause 20 | 21 | # Parameters 22 | # message [String] 23 | # Text message of the reason for the failure and/or where it occurred 24 | # 25 | # server [String] 26 | # Hostname and port 27 | # For example: "localhost:2000" 28 | # 29 | # cause [Exception] 30 | # Original Exception if any, otherwise nil 31 | def initialize(message, server, cause = nil) 32 | @server = server 33 | @cause = cause 34 | super(message) 35 | end 36 | end 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /lib/net/tcp_client/policy/base.rb: -------------------------------------------------------------------------------- 1 | module Net 2 | class TCPClient 3 | module Policy 4 | # Policy for connecting to servers in the order specified 5 | class Base 6 | attr_reader :addresses 7 | 8 | # Returns a policy instance for the supplied policy type 9 | def self.factory(policy, server_names) 10 | case policy 11 | when :ordered 12 | # Policy for connecting to servers in the order specified 13 | Ordered.new(server_names) 14 | when :random 15 | Random.new(server_names) 16 | when Proc 17 | Custom.new(server_names, policy) 18 | else 19 | raise(ArgumentError, "Invalid policy: #{policy.inspect}") 20 | end 21 | end 22 | 23 | def initialize(server_names) 24 | # Collect Addresses for the supplied server_names 25 | @addresses = Array(server_names).collect { |name| Address.addresses_for_server_name(name) }.flatten 26 | end 27 | 28 | # Calls the block once for each server, with the addresses in order 29 | def each(&block) 30 | raise NotImplementedError 31 | end 32 | end 33 | end 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /lib/net/tcp_client/policy/custom.rb: -------------------------------------------------------------------------------- 1 | module Net 2 | class TCPClient 3 | module Policy 4 | # Policy for connecting to servers in the order specified 5 | class Custom < Base 6 | def initialize(server_names, block) 7 | super(server_names) 8 | @block = block 9 | end 10 | 11 | # Calls the block once for each server, with the addresses in the order returned 12 | # by the supplied block. 13 | # The block must return a Net::TCPClient::Address instance, 14 | # or nil to stop trying to connect to servers 15 | # 16 | # Note: 17 | # If every address fails the block will be called constantly until it returns nil. 18 | # 19 | # Example: 20 | # # Returns addresses in random order but without checking if a host name has been used before 21 | # policy.each do |addresses, count| 22 | # # Return nil after the last address has been tried so that retry logic can take over 23 | # if count <= address.size 24 | # addresses.sample 25 | # end 26 | # end 27 | def each(&block) 28 | count = 1 29 | while (address = @block.call(addresses, count)) 30 | unless address.is_a?(Net::TCPClient::Address) || address.nil? 31 | raise(ArgumentError, "Proc must return Net::TCPClient::Address, or nil") 32 | end 33 | 34 | block.call(address) 35 | count += 1 36 | end 37 | end 38 | end 39 | end 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /lib/net/tcp_client/policy/ordered.rb: -------------------------------------------------------------------------------- 1 | module Net 2 | class TCPClient 3 | module Policy 4 | # Policy for connecting to servers in the order specified 5 | class Ordered < Base 6 | # Calls the block once for each server, with the addresses in order 7 | def each(&block) 8 | addresses.each { |address| block.call(address) } 9 | end 10 | end 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /lib/net/tcp_client/policy/random.rb: -------------------------------------------------------------------------------- 1 | module Net 2 | class TCPClient 3 | module Policy 4 | # Policy for connecting to servers in the order specified 5 | class Random < Base 6 | # Calls the block once for each server, with the addresses in random order 7 | def each(&block) 8 | addresses.shuffle.each { |address| block.call(address) } 9 | end 10 | end 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /lib/net/tcp_client/tcp_client.rb: -------------------------------------------------------------------------------- 1 | module Net 2 | # Make Socket calls resilient by adding timeouts, retries and specific 3 | # exception categories 4 | # 5 | # TCP Client with: 6 | # * Connection Timeouts 7 | # Ability to timeout if a connect does not complete within a reasonable time 8 | # For example, this can occur when the server is turned off without shutting down 9 | # causing clients to hang creating new connections 10 | # 11 | # * Automatic retries on startup connection failure 12 | # For example, the server is being restarted while the client is starting 13 | # Gives the server a few seconds to restart to 14 | # 15 | # * Automatic retries on active connection failures 16 | # If the server is restarted during 17 | # 18 | # Connection and Read Timeouts are fully configurable 19 | # 20 | # Raises Net::TCPClient::ConnectionTimeout when the connection timeout is exceeded 21 | # Raises Net::TCPClient::ReadTimeout when the read timeout is exceeded 22 | # Raises Net::TCPClient::ConnectionFailure when a network error occurs whilst reading or writing 23 | # 24 | # Note: Only the following methods currently have auto-reconnect enabled: 25 | # * read 26 | # * write 27 | # 28 | # Future: 29 | # * Add auto-reconnect feature to sysread, syswrite, etc... 30 | # * To be a drop-in replacement to TCPSocket should also need to implement the 31 | # following TCPSocket instance methods: :addr, :peeraddr 32 | # 33 | # Design Notes: 34 | # * Does not inherit from Socket or TCP Socket because the socket instance 35 | # has to be completely destroyed and recreated after a connection failure 36 | # 37 | class TCPClient 38 | include SemanticLogger::Loggable if defined?(SemanticLogger::Loggable) 39 | 40 | attr_accessor :connect_timeout, :read_timeout, :write_timeout, 41 | :connect_retry_count, :connect_retry_interval, :retry_count, 42 | :policy, :close_on_error, :buffered, :ssl, :proxy_server, :keepalive 43 | attr_reader :servers, :address, :socket, :ssl_handshake_timeout 44 | 45 | # Supports embedding user supplied data along with this connection 46 | # such as sequence number and other connection specific information 47 | # Not used or modified by TCPClient 48 | attr_accessor :user_data 49 | 50 | @reconnect_on_errors = [ 51 | Errno::ECONNABORTED, 52 | Errno::ECONNREFUSED, 53 | Errno::ECONNRESET, 54 | Errno::EHOSTUNREACH, 55 | Errno::EIO, 56 | Errno::ENETDOWN, 57 | Errno::ENETRESET, 58 | Errno::EPIPE, 59 | Errno::ETIMEDOUT, 60 | EOFError, 61 | Net::TCPClient::ConnectionTimeout, 62 | IOError 63 | ] 64 | 65 | # Return the array of errors that will result in an automatic connection retry 66 | # To add any additional errors to the standard list: 67 | # Net::TCPClient.reconnect_on_errors << Errno::EPROTO 68 | class << self 69 | attr_reader :reconnect_on_errors 70 | end 71 | 72 | # Create a connection, call the supplied block and close the connection on 73 | # completion of the block 74 | # 75 | # See #initialize for the list of parameters 76 | # 77 | # Example 78 | # Net::TCPClient.connect( 79 | # server: 'server:3300', 80 | # connect_retry_interval: 0.1, 81 | # connect_retry_count: 5 82 | # ) do |client| 83 | # client.retry_on_connection_failure do 84 | # client.write('Update the database') 85 | # end 86 | # response = client.read(20) 87 | # puts "Received: #{response}" 88 | # end 89 | # 90 | def self.connect(params = {}) 91 | connection = new(**params) 92 | yield(connection) 93 | ensure 94 | connection&.close 95 | end 96 | 97 | # Create a new TCP Client connection 98 | # 99 | # Parameters: 100 | # :server [String] 101 | # URL of the server to connect to with port number 102 | # 'localhost:2000' 103 | # '192.168.1.10:80' 104 | # 105 | # :servers [Array of String] 106 | # Array of URL's of servers to connect to with port numbers 107 | # ['server1:2000', 'server2:2000'] 108 | # 109 | # The second server will only be attempted once the first server 110 | # cannot be connected to or has timed out on connect 111 | # A read failure or timeout will not result in switching to the second 112 | # server, only a connection failure or during an automatic reconnect 113 | # 114 | # :connect_timeout [Float] 115 | # Time in seconds to timeout when trying to connect to the server 116 | # A value of -1 will cause the connect wait time to be infinite 117 | # Default: 10 seconds 118 | # 119 | # :read_timeout [Float] 120 | # Time in seconds to timeout on read 121 | # Can be overridden by supplying a timeout in the read call 122 | # Default: 60 123 | # 124 | # :write_timeout [Float] 125 | # Time in seconds to timeout on write 126 | # Can be overridden by supplying a timeout in the write call 127 | # Default: 60 128 | # 129 | # :buffered [true|false] 130 | # Whether to use Nagle's Buffering algorithm (http://en.wikipedia.org/wiki/Nagle's_algorithm) 131 | # Recommend disabling for RPC style invocations where we don't want to wait for an 132 | # ACK from the server before sending the last partial segment 133 | # Buffering is recommended in a browser or file transfer style environment 134 | # where multiple sends are expected during a single response. 135 | # Also sets sync to true if buffered is false so that all data is sent immediately without 136 | # internal buffering. 137 | # Default: true 138 | # 139 | # :keepalive [true|false] 140 | # Makes the OS check connections even when not in use, so that failed connections fail immediately 141 | # upon use instead of possibly taking considerable time to fail. 142 | # Default: true 143 | # 144 | # :connect_retry_count [Integer] 145 | # Number of times to retry connecting when a connection fails 146 | # Default: 10 147 | # 148 | # :connect_retry_interval [Float] 149 | # Number of seconds between connection retry attempts after the first failed attempt 150 | # Default: 0.5 151 | # 152 | # :retry_count [Integer] 153 | # Number of times to retry when calling #retry_on_connection_failure 154 | # This is independent of :connect_retry_count which still applies with 155 | # connection failures. This retry controls upto how many times to retry the 156 | # supplied block should a connection failure occur during the block 157 | # Default: 3 158 | # 159 | # :on_connect [Proc] 160 | # Directly after a connection is established and before it is made available 161 | # for use this Block is invoked. 162 | # Typical Use Cases: 163 | # - Initialize per connection session sequence numbers. 164 | # - Pass authentication information to the server. 165 | # - Perform a handshake with the server. 166 | # 167 | # :policy [Symbol|Proc] 168 | # Specify the policy to use when connecting to servers. 169 | # :ordered 170 | # Select a server in the order supplied in the array, with the first 171 | # having the highest priority. The second server will only be connected 172 | # to if the first server is unreachable 173 | # :random 174 | # Randomly select a server from the list every time a connection 175 | # is established, including during automatic connection recovery. 176 | # :ping_time 177 | # FUTURE - Not implemented yet - Pull request anyone? 178 | # The server with the lowest ping time will be tried first 179 | # Proc: 180 | # When a Proc is supplied, it will be called passing in the list 181 | # of servers. The Proc must return one server name 182 | # Example: 183 | # :policy => Proc.new do |servers| 184 | # servers.last 185 | # end 186 | # Default: :ordered 187 | # 188 | # :close_on_error [True|False] 189 | # To prevent the connection from going into an inconsistent state 190 | # automatically close the connection if an error occurs 191 | # This includes a Read Timeout 192 | # Default: true 193 | # 194 | # :proxy_server [String] 195 | # The host name and port in the form of 'host_name:1234' to forward 196 | # socket connections though. 197 | # Default: nil ( none ) 198 | # 199 | # SSL Options 200 | # :ssl [true|false|Hash] 201 | # true: SSL is enabled using the SSL context defaults. 202 | # false: SSL is not used. 203 | # Hash: 204 | # Keys from OpenSSL::SSL::SSLContext: 205 | # ca_file, ca_path, cert, cert_store, ciphers, key, ssl_timeout, ssl_version 206 | # verify_callback, verify_depth, verify_mode 207 | # handshake_timeout: [Float] 208 | # The number of seconds to timeout the SSL Handshake. 209 | # Default: connect_timeout 210 | # Default: false. 211 | # See OpenSSL::SSL::SSLContext::DEFAULT_PARAMS for the defaults. 212 | # 213 | # Example: 214 | # client = Net::TCPClient.new( 215 | # server: 'server:3300', 216 | # connect_retry_interval: 0.1, 217 | # connect_retry_count: 5 218 | # ) 219 | # 220 | # client.retry_on_connection_failure do 221 | # client.write('Update the database') 222 | # end 223 | # 224 | # # Read upto 20 characters from the server 225 | # response = client.read(20) 226 | # 227 | # puts "Received: #{response}" 228 | # client.close 229 | # 230 | # SSL Example: 231 | # client = Net::TCPClient.new( 232 | # server: 'server:3300', 233 | # connect_retry_interval: 0.1, 234 | # connect_retry_count: 5, 235 | # ssl: true 236 | # ) 237 | # 238 | # SSL with options Example: 239 | # client = Net::TCPClient.new( 240 | # server: 'server:3300', 241 | # connect_retry_interval: 0.1, 242 | # connect_retry_count: 5, 243 | # ssl: { 244 | # verify_mode: OpenSSL::SSL::VERIFY_NONE 245 | # } 246 | # ) 247 | def initialize(server: nil, servers: nil, 248 | policy: :ordered, buffered: true, keepalive: true, 249 | connect_timeout: 10.0, read_timeout: 60.0, write_timeout: 60.0, 250 | connect_retry_count: 10, retry_count: 3, connect_retry_interval: 0.5, close_on_error: true, 251 | on_connect: nil, proxy_server: nil, ssl: nil) 252 | @read_timeout = read_timeout.to_f 253 | @write_timeout = write_timeout.to_f 254 | @connect_timeout = connect_timeout.to_f 255 | @buffered = buffered 256 | @keepalive = keepalive 257 | @connect_retry_count = connect_retry_count 258 | @retry_count = retry_count 259 | @connect_retry_interval = connect_retry_interval.to_f 260 | @on_connect = on_connect 261 | @proxy_server = proxy_server 262 | @policy = policy 263 | @close_on_error = close_on_error 264 | if ssl 265 | @ssl = ssl == true ? {} : ssl 266 | @ssl_handshake_timeout = (@ssl.delete(:handshake_timeout) || @connect_timeout).to_f 267 | end 268 | @servers = [server] if server 269 | @servers = servers if servers 270 | 271 | raise(ArgumentError, "Missing mandatory :server or :servers") unless @servers 272 | 273 | connect 274 | end 275 | 276 | # Connect to the TCP server 277 | # 278 | # Raises Net::TCPClient::ConnectionTimeout when the time taken to create a connection 279 | # exceeds the :connect_timeout 280 | # Raises Net::TCPClient::ConnectionFailure whenever Socket raises an error such as Error::EACCESS etc, see Socket#connect for more information 281 | # 282 | # Error handling is implemented as follows: 283 | # 1. TCP Socket Connect failure: 284 | # Cannot reach server 285 | # Server is being restarted, or is not running 286 | # Retry 50 times every 100ms before raising a Net::TCPClient::ConnectionFailure 287 | # - Means all calls to #connect will take at least 5 seconds before failing if the server is not running 288 | # - Allows hot restart of server process if it restarts within 5 seconds 289 | # 290 | # 2. TCP Socket Connect timeout: 291 | # Timed out after 5 seconds trying to connect to the server 292 | # Usually means server is busy or the remote server disappeared off the network recently 293 | # No retry, just raise a Net::TCPClient::ConnectionTimeout 294 | # 295 | # Note: When multiple servers are supplied it will only try to connect to 296 | # the subsequent servers once the retry count has been exceeded 297 | # 298 | # Note: Calling #connect on an open connection will close the current connection 299 | # and create a new connection 300 | def connect 301 | start_time = Time.now 302 | retries = 0 303 | close 304 | 305 | # Number of times to try 306 | begin 307 | connect_to_server(servers, policy) 308 | logger.info(message: "Connected to #{address}", duration: (Time.now - start_time) * 1000) if respond_to?(:logger) 309 | rescue ConnectionFailure, ConnectionTimeout => e 310 | cause = e.is_a?(ConnectionTimeout) ? e : e.cause 311 | # Retry-able? 312 | if self.class.reconnect_on_errors.include?(cause.class) && (retries < connect_retry_count.to_i) 313 | retries += 1 314 | if respond_to?(:logger) 315 | logger.warn "#connect Failed to connect to any of #{servers.join(',')}. Sleeping:#{connect_retry_interval}s. Retry: #{retries}" 316 | end 317 | sleep(connect_retry_interval) 318 | retry 319 | else 320 | message = "#connect Failed to connect to any of #{servers.join(',')} after #{retries} retries. #{e.class}: #{e.message}" 321 | logger.benchmark_error(message, exception: e, duration: (Time.now - start_time)) if respond_to?(:logger) 322 | raise ConnectionFailure.new(message, address.to_s, cause) 323 | end 324 | end 325 | end 326 | 327 | # Write data to the server 328 | # 329 | # Use #with_retry to add resilience to the #write method 330 | # 331 | # Raises Net::TCPClient::ConnectionFailure whenever the write fails 332 | # For a description of the errors, see Socket#write 333 | # 334 | # Parameters 335 | # timeout [Float] 336 | # Optional: Override the default write timeout for this write 337 | # Number of seconds before raising Net::TCPClient::WriteTimeout when no data has 338 | # been written. 339 | # A value of -1 will wait forever 340 | # Default: :write_timeout supplied to #initialize 341 | # 342 | # Note: After a Net::TCPClient::ReadTimeout #read can be called again on 343 | # the same socket to read the response later. 344 | # If the application no longer wants the connection after a 345 | # Net::TCPClient::ReadTimeout, then the #close method _must_ be called 346 | # before calling _connect_ or _retry_on_connection_failure_ to create 347 | # a new connection 348 | def write(data, timeout = write_timeout) 349 | data = data.to_s 350 | if respond_to?(:logger) 351 | payload = {timeout: timeout} 352 | # With trace level also log the sent data 353 | payload[:data] = data if logger.trace? 354 | logger.benchmark_debug("#write", payload: payload) do 355 | payload[:bytes] = socket_write(data, timeout) 356 | end 357 | else 358 | socket_write(data, timeout) 359 | end 360 | rescue Exception => e 361 | close if close_on_error 362 | raise e 363 | end 364 | 365 | # Returns a response from the server 366 | # 367 | # Raises Net::TCPClient::ConnectionTimeout when the time taken to create a connection 368 | # exceeds the :connect_timeout 369 | # Connection is closed 370 | # Raises Net::TCPClient::ConnectionFailure whenever Socket raises an error such as 371 | # Error::EACCESS etc, see Socket#connect for more information 372 | # Connection is closed 373 | # Raises Net::TCPClient::ReadTimeout if the timeout has been exceeded waiting for the 374 | # requested number of bytes from the server 375 | # Partial data will not be returned 376 | # Connection is _not_ closed and #read can be called again later 377 | # to read the response from the connection 378 | # 379 | # Parameters 380 | # length [Integer] 381 | # The number of bytes to return 382 | # #read will not return until 'length' bytes have been received from 383 | # the server 384 | # 385 | # buffer [String] 386 | # Optional buffer into which to write the data that is read. 387 | # 388 | # timeout [Float] 389 | # Optional: Override the default read timeout for this read 390 | # Number of seconds before raising Net::TCPClient::ReadTimeout when no data has 391 | # been returned 392 | # A value of -1 will wait forever for a response on the socket 393 | # Default: :read_timeout supplied to #initialize 394 | # 395 | # Note: After a Net::TCPClient::ReadTimeout #read can be called again on 396 | # the same socket to read the response later. 397 | # If the application no longer wants the connection after a 398 | # Net::TCPClient::ReadTimeout, then the #close method _must_ be called 399 | # before calling _connect_ or _retry_on_connection_failure_ to create 400 | # a new connection 401 | def read(length, buffer = nil, timeout = read_timeout) 402 | if respond_to?(:logger) 403 | payload = {bytes: length, timeout: timeout} 404 | logger.benchmark_debug("#read", payload: payload) do 405 | data = socket_read(length, buffer, timeout) 406 | # With trace level also log the received data 407 | payload[:data] = data if logger.trace? 408 | data 409 | end 410 | else 411 | socket_read(length, buffer, timeout) 412 | end 413 | rescue Exception => e 414 | close if close_on_error 415 | raise e 416 | end 417 | 418 | # Write and/or receive data with automatic retry on connection failure 419 | # 420 | # On a connection failure, it will create a new connection and retry the block. 421 | # Returns immediately on exception Net::TCPClient::ReadTimeout 422 | # The connection is always closed on Net::TCPClient::ConnectionFailure regardless of close_on_error 423 | # 424 | # 1. Example of a resilient _readonly_ request: 425 | # 426 | # When reading data from a server that does not change state on the server 427 | # Wrap both the write and the read with #retry_on_connection_failure 428 | # since it is safe to write the same data twice to the server 429 | # 430 | # # Since the write can be sent many times it is safe to also put the receive 431 | # # inside the retry block 432 | # value = client.retry_on_connection_failure do 433 | # client.write("GETVALUE:count\n") 434 | # client.read(20).strip.to_i 435 | # end 436 | # 437 | # 2. Example of a resilient request that _modifies_ data on the server: 438 | # 439 | # When changing state on the server, for example when updating a value 440 | # Wrap _only_ the write with #retry_on_connection_failure 441 | # The read must be outside the #retry_on_connection_failure since we must 442 | # not retry the write if the connection fails during the #read 443 | # 444 | # value = 45 445 | # # Only the write is within the retry block since we cannot re-write once 446 | # # the write was successful since the server may have made the change 447 | # client.retry_on_connection_failure do 448 | # client.write("SETVALUE:#{count}\n") 449 | # end 450 | # # Server returns "SAVED" if the call was successful 451 | # result = client.read(20).strip 452 | # 453 | # Error handling is implemented as follows: 454 | # If a network failure occurs during the block invocation the block 455 | # will be called again with a new connection to the server. 456 | # It will only be retried up to 3 times 457 | # The re-connect will independently retry and timeout using all the 458 | # rules of #connect 459 | def retry_on_connection_failure 460 | retries = 0 461 | begin 462 | connect if closed? 463 | yield(self) 464 | rescue ConnectionFailure => e 465 | exc_str = e.cause ? "#{e.cause.class}: #{e.cause.message}" : e.message 466 | # Re-raise exceptions that should not be retried 467 | if !self.class.reconnect_on_errors.include?(e.cause.class) 468 | logger.info "#retry_on_connection_failure not configured to retry: #{exc_str}" if respond_to?(:logger) 469 | raise e 470 | elsif retries < @retry_count 471 | retries += 1 472 | logger.warn "#retry_on_connection_failure retry #{retries} due to #{e.class}: #{e.message}" if respond_to?(:logger) 473 | connect 474 | retry 475 | end 476 | if respond_to?(:logger) 477 | logger.error "#retry_on_connection_failure Connection failure: #{e.class}: #{e.message}. Giving up after #{retries} retries" 478 | end 479 | raise ConnectionFailure.new("After #{retries} retries to any of #{servers.join(',')}': #{exc_str}", servers, e.cause) 480 | end 481 | end 482 | 483 | # Close the socket only if it is not already closed 484 | # 485 | # Logs a warning if an error occurs trying to close the socket 486 | def close 487 | socket.close if socket && !socket.closed? 488 | @socket = nil 489 | @address = nil 490 | true 491 | rescue IOError => e 492 | logger.warn "IOError when attempting to close socket: #{e.class}: #{e.message}" if respond_to?(:logger) 493 | false 494 | end 495 | 496 | def flush 497 | return unless socket 498 | 499 | respond_to?(:logger) ? logger.benchmark_debug("#flush") { socket.flush } : socket.flush 500 | end 501 | 502 | def closed? 503 | socket.nil? || socket.closed? 504 | end 505 | 506 | def eof? 507 | socket.nil? || socket.eof? 508 | end 509 | 510 | # Returns whether the connection to the server is alive 511 | # 512 | # It is useful to call this method before making a call to the server 513 | # that would change data on the server 514 | # 515 | # Note: This method is only useful if the server closed the connection or 516 | # if a previous connection failure occurred. 517 | # If the server is hard killed this will still return true until one 518 | # or more writes are attempted 519 | # 520 | # Note: In testing the overhead of this call is rather low, with the ability to 521 | # make about 120,000 calls per second against an active connection. 522 | # I.e. About 8.3 micro seconds per call 523 | def alive? 524 | return false if socket.nil? || closed? 525 | 526 | if IO.select([socket], nil, nil, 0) 527 | begin 528 | !socket.eof? 529 | rescue StandardError 530 | false 531 | end 532 | else 533 | true 534 | end 535 | rescue IOError 536 | false 537 | end 538 | 539 | def setsockopt(*args) 540 | socket.nil? || socket.setsockopt(*args) 541 | end 542 | 543 | private 544 | 545 | # Connect to one of the servers in the list, per the current policy 546 | # Returns [Socket] the socket connected to or an Exception 547 | def connect_to_server(servers, policy) 548 | # Iterate over each server address until it successfully connects to a host 549 | last_exception = nil 550 | Policy::Base.factory(policy, servers).each do |address| 551 | begin 552 | return connect_to_address(address) 553 | rescue ConnectionTimeout, ConnectionFailure => e 554 | last_exception = e 555 | end 556 | end 557 | 558 | # Raise Exception once it has failed to connect to any server 559 | last_exception ? raise(last_exception) : raise(ArgumentError, "No servers supplied to connect to: #{servers.join(',')}") 560 | end 561 | 562 | # Returns [Socket] connected to supplied address 563 | # address [Net::TCPClient::Address] 564 | # Host name, ip address and port of server to connect to 565 | # Connect to the server at the supplied address 566 | # Returns the socket connection 567 | def connect_to_address(address) 568 | socket = 569 | if proxy_server 570 | ::SOCKSSocket.new("#{address.ip_address}:#{address.port}", proxy_server) 571 | else 572 | ::Socket.new(Socket::AF_INET, Socket::SOCK_STREAM, 0) 573 | end 574 | unless buffered 575 | socket.sync = true 576 | socket.setsockopt(Socket::IPPROTO_TCP, Socket::TCP_NODELAY, 1) 577 | end 578 | socket.setsockopt(Socket::SOL_SOCKET, Socket::SO_KEEPALIVE, true) if keepalive 579 | 580 | socket_connect(socket, address, connect_timeout) 581 | 582 | @socket = ssl ? ssl_connect(socket, address, ssl_handshake_timeout) : socket 583 | @address = address 584 | 585 | # Invoke user supplied Block every time a new connection has been established 586 | @on_connect&.call(self) 587 | end 588 | 589 | # Connect to server 590 | # 591 | # Raises Net::TCPClient::ConnectionTimeout when the connection timeout has been exceeded 592 | # Raises Net::TCPClient::ConnectionFailure 593 | def socket_connect(socket, address, timeout) 594 | socket_address = Socket.pack_sockaddr_in(address.port, address.ip_address) 595 | 596 | # Timeout of -1 means wait forever for a connection 597 | return socket.connect(socket_address) if timeout == -1 598 | 599 | deadline = Time.now.utc + timeout 600 | begin 601 | non_blocking(socket, deadline) { socket.connect_nonblock(socket_address) } 602 | rescue Errno::EISCONN 603 | # Connection was successful. 604 | rescue NonBlockingTimeout 605 | raise ConnectionTimeout, "Timed out after #{timeout} seconds trying to connect to #{address}" 606 | rescue SystemCallError, IOError => e 607 | message = "#connect Connection failure connecting to '#{address}': #{e.class}: #{e.message}" 608 | logger.error message if respond_to?(:logger) 609 | raise ConnectionFailure.new(message, address.to_s, e) 610 | end 611 | end 612 | 613 | # Write to the socket 614 | def socket_write(data, timeout) 615 | if timeout.negative? 616 | socket.write(data) 617 | else 618 | deadline = Time.now.utc + timeout 619 | length = data.bytesize 620 | total_count = 0 621 | non_blocking(socket, deadline) do 622 | loop do 623 | begin 624 | count = socket.write_nonblock(data) 625 | rescue Errno::EWOULDBLOCK 626 | retry 627 | end 628 | total_count += count 629 | return total_count if total_count >= length 630 | 631 | data = data.byteslice(count..-1) 632 | end 633 | end 634 | end 635 | rescue NonBlockingTimeout 636 | logger.warn "#write Timeout after #{timeout} seconds" if respond_to?(:logger) 637 | raise WriteTimeout, "Timed out after #{timeout} seconds trying to write to #{address}" 638 | rescue SystemCallError, IOError => e 639 | message = "#write Connection failure while writing to '#{address}': #{e.class}: #{e.message}" 640 | logger.error message if respond_to?(:logger) 641 | raise ConnectionFailure.new(message, address.to_s, e) 642 | end 643 | 644 | def socket_read(length, buffer, timeout) 645 | result = 646 | if timeout.negative? 647 | buffer.nil? ? socket.read(length) : socket.read(length, buffer) 648 | else 649 | deadline = Time.now.utc + timeout 650 | non_blocking(socket, deadline) do 651 | buffer.nil? ? socket.read_nonblock(length) : socket.read_nonblock(length, buffer) 652 | end 653 | end 654 | 655 | # EOF before all the data was returned 656 | if result.nil? || (result.length < length) 657 | logger.warn "#read server closed the connection before #{length} bytes were returned" if respond_to?(:logger) 658 | raise ConnectionFailure.new("Connection lost while reading data", address.to_s, EOFError.new("end of file reached")) 659 | end 660 | result 661 | rescue NonBlockingTimeout 662 | logger.warn "#read Timeout after #{timeout} seconds" if respond_to?(:logger) 663 | raise ReadTimeout, "Timed out after #{timeout} seconds trying to read from #{address}" 664 | rescue SystemCallError, IOError => e 665 | message = "#read Connection failure while reading data from '#{address}': #{e.class}: #{e.message}" 666 | logger.error message if respond_to?(:logger) 667 | raise ConnectionFailure.new(message, address.to_s, e) 668 | end 669 | 670 | class NonBlockingTimeout < ::SocketError 671 | end 672 | 673 | def non_blocking(socket, deadline) 674 | yield 675 | rescue IO::WaitReadable 676 | time_remaining = check_time_remaining(deadline) 677 | raise NonBlockingTimeout unless IO.select([socket], nil, nil, time_remaining) 678 | 679 | retry 680 | rescue IO::WaitWritable 681 | time_remaining = check_time_remaining(deadline) 682 | raise NonBlockingTimeout unless IO.select(nil, [socket], nil, time_remaining) 683 | 684 | retry 685 | end 686 | 687 | def check_time_remaining(deadline) 688 | time_remaining = deadline - Time.now.utc 689 | raise NonBlockingTimeout if time_remaining.negative? 690 | 691 | time_remaining 692 | end 693 | 694 | # Try connecting to a single server 695 | # Returns the connected socket 696 | # 697 | # Raises Net::TCPClient::ConnectionTimeout when the connection timeout has been exceeded 698 | # Raises Net::TCPClient::ConnectionFailure 699 | def ssl_connect(socket, address, timeout) 700 | ssl_context = OpenSSL::SSL::SSLContext.new 701 | ssl_context.set_params(ssl.is_a?(Hash) ? ssl : {}) 702 | 703 | ssl_socket = OpenSSL::SSL::SSLSocket.new(socket, ssl_context) 704 | ssl_socket.hostname = address.host_name 705 | ssl_socket.sync_close = true 706 | 707 | begin 708 | if timeout == -1 709 | # Timeout of -1 means wait forever for a connection 710 | ssl_socket.connect 711 | else 712 | deadline = Time.now.utc + timeout 713 | begin 714 | non_blocking(socket, deadline) { ssl_socket.connect_nonblock } 715 | rescue Errno::EISCONN 716 | # Connection was successful. 717 | rescue NonBlockingTimeout 718 | raise ConnectionTimeout, "SSL handshake Timed out after #{timeout} seconds trying to connect to #{address}" 719 | end 720 | end 721 | rescue SystemCallError, OpenSSL::SSL::SSLError, IOError => e 722 | message = "#connect SSL handshake failure with '#{address}': #{e.class}: #{e.message}" 723 | logger.error message if respond_to?(:logger) 724 | raise ConnectionFailure.new(message, address.to_s, e) 725 | end 726 | 727 | # Verify Peer certificate 728 | ssl_verify(ssl_socket, address) if ssl_context.verify_mode != OpenSSL::SSL::VERIFY_NONE 729 | ssl_socket 730 | end 731 | 732 | # Raises Net::TCPClient::ConnectionFailure if the peer certificate does not match its hostname 733 | def ssl_verify(ssl_socket, address) 734 | return if OpenSSL::SSL.verify_certificate_identity(ssl_socket.peer_cert, address.host_name) 735 | 736 | domains = extract_domains_from_cert(ssl_socket.peer_cert) 737 | ssl_socket.close 738 | message = "#connect SSL handshake failed due to a hostname mismatch. Request address was: '#{address}'" \ 739 | " Certificate valid for hostnames: #{domains.map { |d| "'#{d}'" }.join(',')}" 740 | logger.error message if respond_to?(:logger) 741 | raise ConnectionFailure.new(message, address.to_s) 742 | end 743 | 744 | def extract_domains_from_cert(cert) 745 | cert.subject.to_a.each do |oid, value| 746 | return [value] if oid == "CN" 747 | end 748 | end 749 | end 750 | end 751 | -------------------------------------------------------------------------------- /lib/net/tcp_client/version.rb: -------------------------------------------------------------------------------- 1 | module Net 2 | class TCPClient 3 | VERSION = "2.2.1".freeze 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /lib/net_tcp_client.rb: -------------------------------------------------------------------------------- 1 | require "net/tcp_client" 2 | -------------------------------------------------------------------------------- /net_tcp_client.gemspec: -------------------------------------------------------------------------------- 1 | lib = File.expand_path("lib", __dir__) 2 | $:.unshift lib unless $:.include?(lib) 3 | 4 | # Gem's version: 5 | require "net/tcp_client/version" 6 | 7 | # Gem Declaration: 8 | Gem::Specification.new do |spec| 9 | spec.name = "net_tcp_client" 10 | spec.version = Net::TCPClient::VERSION 11 | spec.platform = Gem::Platform::RUBY 12 | spec.authors = ["Reid Morrison"] 13 | spec.homepage = "https://github.com/reidmorrison/net_tcp_client" 14 | spec.summary = "Net::TCPClient is a TCP Socket Client with built-in timeouts, retries, and logging" 15 | spec.description = "Net::TCPClient implements resilience features that many developers wish was already included in the standard Ruby libraries." 16 | spec.files = Dir["lib/**/*", "LICENSE.txt", "Rakefile", "README.md"] 17 | spec.license = "Apache-2.0" 18 | spec.required_ruby_version = ">= 2.3" 19 | spec.metadata["rubygems_mfa_required"] = "true" 20 | end 21 | -------------------------------------------------------------------------------- /test/address_test.rb: -------------------------------------------------------------------------------- 1 | require_relative "test_helper" 2 | require "ipaddr" 3 | 4 | class Net::TCPClient::AddressTest < Minitest::Test 5 | describe Net::TCPClient::Address do 6 | describe ".ip_addresses" do 7 | it "returns the ip addresses for a known DNS" do 8 | ips = Net::TCPClient::Address.ip_addresses("google.com") 9 | assert ips.count.positive? 10 | ips.each do |ip| 11 | # Validate IP Addresses 12 | IPAddr.new(ip) 13 | end 14 | end 15 | 16 | it "returns an ip address" do 17 | ips = Net::TCPClient::Address.ip_addresses("127.0.0.1") 18 | assert_equal 1, ips.count 19 | assert_equal "127.0.0.1", ips.first 20 | end 21 | end 22 | 23 | describe ".addresses" do 24 | it "returns one address for a known DNS" do 25 | addresses = Net::TCPClient::Address.addresses("localhost", 80) 26 | assert_equal 1, addresses.count, addresses.ai 27 | address = addresses.first 28 | assert_equal 80, address.port 29 | assert_equal "127.0.0.1", address.ip_address 30 | assert_equal "localhost", address.host_name 31 | end 32 | 33 | it "returns addresses for a DNS with mutiple IPs" do 34 | addresses = Net::TCPClient::Address.addresses("google.com", 80) 35 | assert addresses.count.positive? 36 | addresses.each do |address| 37 | # Validate IP Addresses 38 | IPAddr.new(address.ip_address) 39 | assert_equal 80, address.port 40 | assert_equal "google.com", address.host_name 41 | end 42 | end 43 | 44 | it "returns an ip address" do 45 | addresses = Net::TCPClient::Address.addresses("127.0.0.1", 80) 46 | assert_equal 1, addresses.count 47 | address = addresses.first 48 | assert_equal 80, address.port 49 | assert_equal "127.0.0.1", address.ip_address 50 | assert_equal "127.0.0.1", address.host_name 51 | end 52 | end 53 | 54 | describe ".addresses_for_server_name" do 55 | it "returns addresses for server name" do 56 | addresses = Net::TCPClient::Address.addresses_for_server_name("localhost:80") 57 | assert_equal 1, addresses.count, addresses.ai 58 | address = addresses.first 59 | assert_equal 80, address.port 60 | assert_equal "127.0.0.1", address.ip_address 61 | assert_equal "localhost", address.host_name 62 | end 63 | 64 | it "returns an ip address" do 65 | addresses = Net::TCPClient::Address.addresses_for_server_name("127.0.0.1:80") 66 | assert_equal 1, addresses.count 67 | address = addresses.first 68 | assert_equal 80, address.port 69 | assert_equal "127.0.0.1", address.ip_address 70 | assert_equal "127.0.0.1", address.host_name 71 | end 72 | end 73 | 74 | describe ".new" do 75 | it "creates an address" do 76 | address = Net::TCPClient::Address.new("host_name", "ip_address", "2000") 77 | assert_equal "host_name", address.host_name 78 | assert_equal "ip_address", address.ip_address 79 | assert_equal 2000, address.port 80 | end 81 | end 82 | 83 | describe "#to_s" do 84 | it "returns a string of the address" do 85 | address = Net::TCPClient::Address.new("host_name", "ip_address", "2000") 86 | assert_equal "host_name[ip_address]:2000", address.to_s 87 | end 88 | end 89 | end 90 | end 91 | -------------------------------------------------------------------------------- /test/policy/custom_policy_test.rb: -------------------------------------------------------------------------------- 1 | require_relative "../test_helper" 2 | class Net::TCPClient::Policy::CustomTest < Minitest::Test 3 | describe Net::TCPClient::Policy::Custom do 4 | describe "#each" do 5 | before do 6 | @proc = lambda do |addresses, count| 7 | addresses[count - 1] 8 | end 9 | end 10 | 11 | it "must return one server, once" do 12 | servers = ["localhost:80"] 13 | policy = Net::TCPClient::Policy::Custom.new(servers, @proc) 14 | collected = [] 15 | policy.each { |address| collected << address } 16 | assert_equal 1, collected.size 17 | address = collected.first 18 | assert_equal 80, address.port 19 | assert_equal "localhost", address.host_name 20 | assert_equal "127.0.0.1", address.ip_address 21 | end 22 | 23 | it "must return the servers in the supplied order" do 24 | servers = %w[localhost:80 127.0.0.1:2000 lvh.me:2100] 25 | policy = Net::TCPClient::Policy::Custom.new(servers, @proc) 26 | names = [] 27 | policy.each { |address| names << address.host_name } 28 | assert_equal %w[localhost 127.0.0.1 lvh.me], names 29 | end 30 | 31 | it "must handle an empty list of servers" do 32 | servers = [] 33 | policy = Net::TCPClient::Policy::Custom.new(servers, @proc) 34 | names = [] 35 | policy.each { |address| names << address.host_name } 36 | assert_equal [], names 37 | end 38 | end 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /test/policy/ordered_policy_test.rb: -------------------------------------------------------------------------------- 1 | require_relative "../test_helper" 2 | class Net::TCPClient::Policy::OrderedTest < Minitest::Test 3 | describe Net::TCPClient::Policy::Ordered do 4 | describe "#each" do 5 | it "must return one server, once" do 6 | servers = ["localhost:80"] 7 | policy = Net::TCPClient::Policy::Ordered.new(servers) 8 | collected = [] 9 | policy.each { |address| collected << address } 10 | assert_equal 1, collected.size 11 | address = collected.first 12 | assert_equal 80, address.port 13 | assert_equal "localhost", address.host_name 14 | assert_equal "127.0.0.1", address.ip_address 15 | end 16 | 17 | it "must return the servers in the supplied order" do 18 | servers = %w[localhost:80 127.0.0.1:2000 lvh.me:2100] 19 | policy = Net::TCPClient::Policy::Ordered.new(servers) 20 | names = [] 21 | policy.each { |address| names << address.host_name } 22 | assert_equal %w[localhost 127.0.0.1 lvh.me], names 23 | end 24 | 25 | it "must handle an empty list of servers" do 26 | servers = [] 27 | policy = Net::TCPClient::Policy::Ordered.new(servers) 28 | names = [] 29 | policy.each { |address| names << address.host_name } 30 | assert_equal [], names 31 | end 32 | end 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /test/policy/random_policy_test.rb: -------------------------------------------------------------------------------- 1 | require_relative "../test_helper" 2 | class Net::TCPClient::Policy::RandomTest < Minitest::Test 3 | describe Net::TCPClient::Policy::Random do 4 | describe "#each" do 5 | it "must return one server, once" do 6 | servers = ["localhost:80"] 7 | policy = Net::TCPClient::Policy::Random.new(servers) 8 | collected = [] 9 | policy.each { |address| collected << address } 10 | assert_equal 1, collected.size 11 | address = collected.first 12 | assert_equal 80, address.port 13 | assert_equal "localhost", address.host_name 14 | assert_equal "127.0.0.1", address.ip_address 15 | end 16 | 17 | it "must return the servers in random order" do 18 | servers = %w[localhost:80 127.0.0.1:2000 lvh.me:2100] 19 | policy = Net::TCPClient::Policy::Random.new(servers) 20 | 21 | names = [] 22 | # It is possible the random order is the supplied order. 23 | # Keep retrying until the order is different. 24 | 3.times do 25 | policy.each { |address| names << address.host_name } 26 | break if names != %w[localhost 127.0.0.1 lvh.me] 27 | 28 | names = [] 29 | end 30 | 31 | refute_equal %w[localhost 127.0.0.1 lvh.me], names 32 | assert_equal %w[localhost 127.0.0.1 lvh.me].sort, names.sort 33 | end 34 | 35 | it "must handle an empty list of servers" do 36 | servers = [] 37 | policy = Net::TCPClient::Policy::Random.new(servers) 38 | names = [] 39 | policy.each { |address| names << address.host_name } 40 | assert_equal [], names 41 | end 42 | end 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /test/simple_tcp_server.rb: -------------------------------------------------------------------------------- 1 | require "socket" 2 | require "openssl" 3 | require "bson" 4 | require "semantic_logger" 5 | 6 | # Read the bson document, returning nil if the IO is closed 7 | # before receiving any data or a complete BSON document 8 | def read_bson_document(io) 9 | bytebuf = BSON::ByteBuffer.new 10 | # Read 4 byte size of following BSON document 11 | bytes = io.read(4) 12 | return unless bytes 13 | 14 | # Read BSON document 15 | sz = bytes.unpack1("V") 16 | bytebuf.put_bytes(bytes) 17 | bytes = io.read(sz - 4) 18 | return unless bytes 19 | 20 | bytebuf.put_bytes(bytes) 21 | Hash.from_bson(bytebuf) 22 | end 23 | 24 | def ssl_file_path(name) 25 | File.join(File.dirname(__FILE__), "ssl_files", name) 26 | end 27 | 28 | # Simple single threaded server for testing purposes using a local socket 29 | # Sends and receives BSON Messages 30 | class SimpleTCPServer 31 | include SemanticLogger::Loggable 32 | attr_accessor :thread, :server 33 | attr_reader :port, :name, :ssl 34 | 35 | def initialize(options = {}) 36 | @port = (options[:port] || 2000).to_i 37 | @name = options[:name] || "tcp" 38 | @ssl = options[:ssl] || false 39 | start 40 | end 41 | 42 | def start 43 | tcp_server = TCPServer.open(port) 44 | 45 | if ssl 46 | context = OpenSSL::SSL::SSLContext.new.tap do |context| 47 | context.set_params(ssl) 48 | context.servername_cb = proc { |_socket, name| 49 | if name == "localhost" 50 | OpenSSL::SSL::SSLContext.new.tap do |new_context| 51 | new_context.cert = OpenSSL::X509::Certificate.new(File.open(ssl_file_path("localhost-server.pem"))) 52 | new_context.key = OpenSSL::PKey::RSA.new(File.open(ssl_file_path("localhost-server-key.pem"))) 53 | new_context.ca_file = ssl_file_path("ca.pem") 54 | end 55 | else 56 | OpenSSL::SSL::SSLContext.new.tap do |new_context| 57 | new_context.cert = OpenSSL::X509::Certificate.new(File.open(ssl_file_path("no-sni.pem"))) 58 | new_context.key = OpenSSL::PKey::RSA.new(File.open(ssl_file_path("no-sni-key.pem"))) 59 | new_context.ca_file = ssl_file_path("ca.pem") 60 | end 61 | end 62 | } 63 | end 64 | tcp_server = OpenSSL::SSL::SSLServer.new(tcp_server, context) 65 | end 66 | 67 | self.server = tcp_server 68 | self.thread = Thread.new do 69 | begin 70 | loop do 71 | logger.debug "Waiting for a client to connect" 72 | 73 | # Wait for a client to connect 74 | on_request(server.accept) 75 | end 76 | rescue IOError, Errno::EBADF => e 77 | logger.info("Thread terminated", e) 78 | end 79 | end 80 | end 81 | 82 | def stop 83 | if thread 84 | thread.kill 85 | thread.join 86 | self.thread = nil 87 | end 88 | begin 89 | server&.close 90 | rescue IOError 91 | end 92 | end 93 | 94 | # Called for each message received from the client 95 | # Returns a Hash that is sent back to the caller 96 | def on_message(message) 97 | case message["action"] 98 | when "test1" 99 | {"result" => "test1"} 100 | when "servername" 101 | {"result" => @name} 102 | when "sleep" 103 | sleep message["duration"] || 1 104 | {"result" => "sleep"} 105 | when "fail" 106 | {"result" => "fail"} if message["attempt"].to_i >= 2 107 | else 108 | {"result" => "Unknown action: #{message['action']}"} 109 | end 110 | end 111 | 112 | # Called for each client connection 113 | # In a real server each request would be handled in a separate thread 114 | def on_request(client) 115 | logger.debug "Client connected, waiting for data from client" 116 | 117 | while (request = read_bson_document(client)) 118 | logger.debug "Received request", request 119 | break unless request 120 | 121 | if (reply = on_message(request)) 122 | logger.debug "Sending Reply" 123 | logger.trace "Reply", reply 124 | client.print(reply.to_bson) 125 | else 126 | logger.debug "Closing client since no reply is being sent back" 127 | server.close 128 | client.close 129 | logger.debug "Server closed" 130 | start 131 | logger.debug "Server Restarted" 132 | break 133 | end 134 | end 135 | # Disconnect from the client 136 | client.close 137 | logger.debug "Disconnected from the client" 138 | end 139 | end 140 | 141 | if $0 == __FILE__ 142 | SemanticLogger.default_level = :trace 143 | SemanticLogger.add_appender(STDOUT) 144 | server = SimpleTCPServer.new(port: 2000) 145 | 146 | # For SSL: 147 | # server = SimpleTCPServer.new( 148 | # port: 2000, 149 | # ssl: { 150 | # cert: ssl_file_path('localhost-server.pem'), 151 | # key: ssl_file_path('localhost-server-key.pem'), 152 | # ca_file: ssl_file_path('ca.pem') 153 | # } 154 | # ) 155 | 156 | server.thread.join 157 | end 158 | -------------------------------------------------------------------------------- /test/ssl_files/ca.key: -------------------------------------------------------------------------------- 1 | -----BEGIN RSA PRIVATE KEY----- 2 | MIIEowIBAAKCAQEA2kw7kGhFXbi4KG4L7G5m0cUbbGYK98Y7A2v1h6HMM57qdN1+ 3 | obz3RzFJRWV2xmzQ6uNBdJU8tkVDnvNOAwusHkTVMwUpgV+TK94U93NtVMl1Q9ah 4 | sGEe2s+JzMucHnGwLMDKKp9C8n3ILcpVaviQUgOqqc1JbO3IcaRUgm7RbN5BStrX 5 | 9IPe5N06VLhOjdoKjb1MtZ6HfP6y3oAo1WnkY/BI7Vs7T52sqlWPtLYvUEq91riy 6 | ZUgdlcSi4vEGuT/r7IU52xtnjrPxfqcrZJHULjyylCRqrXCUU61umEIksQWtHYEH 7 | Q2j+lvI+fX+m4g1bwCmZyyX9wJr3a7db6+9XgwIDAQABAoIBAG1KW0vaGFhqwbBk 8 | IA4X29xL7YXgtL8F/MeixkNIav6xIjquJdb9z2NSNpfKy6NeGV5vtnaSvNmYZdlv 9 | gHAf6OUimwa3H+eInRsKTb7xiBw53D7BdyPiC9uKqjfg/GF1k7lkMBMUtyTGenEK 10 | aqdqmH6nHUtz3r3tcjwLBNBkgO8ajgy+Tld9XBmgbUYMJCYblS/1ZVl1dh4L9qOi 11 | yjHhTZ87X+0pVujWzspw/lgt00S+AIBMWLkYhSaW11LgXsR7837ps9F62fNHfArY 12 | 0Zad7LtzjSNqJJ/lNYxyip3fo3UcrpCuhuIKOudNC1FGgmUdKQlMpOBT1E71yvEX 13 | W9BWA8ECgYEA8+nR7cGo9kDu72CgxGSwmU8DFhPD1ArzdUBElhaWd2vA4lsjlLNv 14 | 0lS0UVLDb6Nd3fWZxJEftp89xx/j1PcPVlMPcg3/yrOrfx7K6o7cz3Ji49j+6kgo 15 | eZ6fhnGtNQ0oZ8BHyNp59lVmFJpZJPOAmMGk6YqzcIa3LX87IK5aE6ECgYEA5R12 16 | 5xRPxDQyxiXCBn7lLZZfmnScR0NewjB/lDz8DFGVX/9adMaQLgsCvg62XxA+99B6 17 | 0upQeQI+8BjJAWC74tfIcpFgVuhFIVhrB//tzvUp/J3MzB3EdgsO1bXyQ6AmPX8j 18 | qod6i4BlJ8E6P/V3U+Tr7TgF+G5L+x4w7rnl2KMCgYBCyIqKJrQ0eKLzN+nM3CTe 19 | VRvrN44uyLDQMcCVt6mLGR2+3GVpmZfMZxTYD2kjb/+LfmuTvoiIYCFyG/Etple0 20 | sxlPiTW4MmmKbMvyXRtoUVFyQT/KtecfJadYEFf0Zp3himwdOnSaVdeVXI176JAV 21 | Qy/8IdXvwXL2KhfuYs6XAQKBgQDQg2w/CaE+szKyWpKmTr5MKtp/OzkvMgT/Phwd 22 | 0RKiM216nG66cCuve53XUpRvF932sunVIiJyvrSA1k24z0yvOirW+a9v6JthqZJf 23 | CXBoNX8sxIAqE71EoPOzU49UNGAY/6h5/ips40EsWRKmOsOKuoBst9vXKKpFtEhc 24 | OxsPeQKBgBqkylA/tOaiWivaL9JTEgPkqOBu1GsuPihT7UnCqVCrRmWysrXXjbUp 25 | C+AlxkRmKIgHhGPyCdVxWW7RB8JZ1mHbuxeRU5Es9v/yLLRyEuCgPuFx3Odi7sLv 26 | SGHEzK5lu4MlZIkonsEJwti/FFguGQpktrH8ltpE7wdw7Ik+zb4z 27 | -----END RSA PRIVATE KEY----- 28 | -------------------------------------------------------------------------------- /test/ssl_files/ca.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIIDFjCCAf4CCQD/szfmoKirZDANBgkqhkiG9w0BAQsFADBMMQswCQYDVQQGEwJV 3 | UzERMA8GA1UECAwITmVicmFza2ExDjAMBgNVBAcMBU9tYWhhMRowGAYDVQQKDBFu 4 | ZXRfdGNwX2NsaWVudF9jYTAgFw0xOTAxMDkxNTE4NDRaGA8yMTE4MTIxNjE1MTg0 5 | NFowTDELMAkGA1UEBhMCVVMxETAPBgNVBAgMCE5lYnJhc2thMQ4wDAYDVQQHDAVP 6 | bWFoYTEaMBgGA1UECgwRbmV0X3RjcF9jbGllbnRfY2EwggEiMA0GCSqGSIb3DQEB 7 | AQUAA4IBDwAwggEKAoIBAQDaTDuQaEVduLgobgvsbmbRxRtsZgr3xjsDa/WHocwz 8 | nup03X6hvPdHMUlFZXbGbNDq40F0lTy2RUOe804DC6weRNUzBSmBX5Mr3hT3c21U 9 | yXVD1qGwYR7az4nMy5wecbAswMoqn0LyfcgtylVq+JBSA6qpzUls7chxpFSCbtFs 10 | 3kFK2tf0g97k3TpUuE6N2gqNvUy1nod8/rLegCjVaeRj8EjtWztPnayqVY+0ti9Q 11 | Sr3WuLJlSB2VxKLi8Qa5P+vshTnbG2eOs/F+pytkkdQuPLKUJGqtcJRTrW6YQiSx 12 | Ba0dgQdDaP6W8j59f6biDVvAKZnLJf3Amvdrt1vr71eDAgMBAAEwDQYJKoZIhvcN 13 | AQELBQADggEBAHSf+IOhDgw64nUuXZYVIK4TQDzqIpnZ8+djkvfqck+WcQmXQ5Vj 14 | G4/8kXgghpA3N63XARb0QOpHk4yvPZKCx+k4xCUbbsvZClAS0ZMQlHpJKLF0xSiA 15 | J9KaOj7HR5044To/McIHllQ812miWqmLtq6eaCxxzNILjzs1fVh4OXHM1ZbCyJGt 16 | 8ekvy1GFNoJFaNvcUvPLFi+PmfArpooVRhON1aeMgk8+pgOJNCakKv7/+QFKPxPC 17 | dWBVr/roSf9w1UbVEGNTUCbBjHVhlyeRRtMtzIfPfo8O/85Ie+P3JPEfS9hYmdb4 18 | At7Gd0dMDALWDPxf6OIxT/LU4ryuf0SDmJE= 19 | -----END CERTIFICATE----- 20 | -------------------------------------------------------------------------------- /test/ssl_files/localhost-server-key.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN RSA PRIVATE KEY----- 2 | MIIEogIBAAKCAQEAryTR52Sowr8yOY1uBgav9VrXF6bXblh4K+aIo+LZk6qjCccv 3 | d/Z8+nx4HER7I3ZqKnEJVDFwgJTIMIrK6mnrP15B8k4aT/N5mWdJzy3BbhV6pa57 4 | IaZerJUaFZYrwL0fSJX8o4iJ5z7WTPkp8J+QHqsyHwdi42gcBW4eHNvPc469dszB 5 | UXdV5IWy0kRw/KYEAqz57d/UQ07kZNi8d/eowc20+CNYNL1Yudbh9zENxa90cVxr 6 | ImQ6mAaWP8X8jYbTbYyshzrhp453N3lGyt9YrZ347IYIdyRQiD57ZEvd+/RsSUsN 7 | D17cKoqxu1mjL9CZV8Mc6AP5LkaAJeNhyzOqEQIDAQABAoIBAFyGeRVjCfyIAUKC 8 | QsOQONjHeqYWD+1Nc37NtRXPO95U4PjDb4JSh0fVBab5Tow3fHKbcLA3xhVHhFKQ 9 | oA4ikpLReslFFYVzPKQb+tQmee9sDXUFririt1U5F6SbxtV1k9dG1UaXVTMC8TeM 10 | 0ek6gmqRSlM2FbnJQDRmLiZUwU1sFVUdpqojxHR+QpIdntPIeW2+Cu/bF8y+ApKt 11 | 7MqvsLEfCCWdo5f5+ALqz1lW90eJHc0KxCogSRsTGt00rJd8ddrgtyiNyWi8NJQg 12 | pRC5e+N7/m3miVyAuMz5FUUGy92LAO7PqWJOCT8P51ktwL2Hc/HXoQvbgWnDey1N 13 | 9HL5zYECgYEA1A828fPhiRqsDuXOZOYRfXPDa3jcvV0IeBw9e0cLOT4CS9bKrri/ 14 | gR5XHAsPoIlAFrgdjPuDLubVb+PaIk0rrZLSjkqxymoz5t/wVmwKcRO7xh1/wYRv 15 | dMfIZA0huubt//b+QiOtPdC3BfrphdJZWRQAvQcKt1phfclaIsmM2pkCgYEA029m 16 | oxOB58KoYzFMlrQFe/9wt2RNE+na8mDxvY5aNrE2INrpTB0sHWbNaN+WeK2yEPER 17 | MVCIy3x0lbozNR/Mm8QrsR5+VwhPoCXtC/wdpMuWf3UH2aPiYcP3i8haiFH52X1u 18 | 2UAyyGLHsHnYZvyKvvJDoeH5paOmCKQDqlU7rjkCgYANWOvPbNdMRuZ/hY1pImYF 19 | bGznbdMPBDUNQlHIWZ9mOfXxChL1zmEXYm5/MF8Kbrke9PW/MvF92T+j7EaFlC/k 20 | m/IuzJrGL8sWhA/fkKtTlLdj7+Vjq89MHWsKiR0PY4ulacl1JkO4OVPbx4A9UREY 21 | nz6wpynQgprSTQMkX2VDOQKBgHELvvSyGWKw0Rc35Jsu5T/G850aI5viDQ5KhvWy 22 | hsl3NlmaseHgNxYBQRIxeWJMfEhSm76iMIGbqTnktDxTJDKkUDgC9cnSx7/4hyVB 23 | Rxg1QeIj0G6tEPz0qgYyuTTpn4yJZBsEGCLLrbjNbMajgAtXvJFxIOlO9hbomo0X 24 | xTEBAoGARjhBRElqj2VRse+eD8+xCVaBm6i+Gvo66626RLNnLF8GtZrX6othpw8x 25 | m9LyZ8ybLfzvyGwg3uqsaCOtP9J9f8Ln2cm+AViqw/75A6n2f/F9UucmZfzvEamZ 26 | 9dc7dN3FfR/1OWc9389Mx8in/+6QLGOaQwUu2uAsSjhRwbcTq/o= 27 | -----END RSA PRIVATE KEY----- 28 | -------------------------------------------------------------------------------- /test/ssl_files/localhost-server.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIIDJzCCAg8CCQDRgr9ZEAyEkTANBgkqhkiG9w0BAQUFADBMMQswCQYDVQQGEwJV 3 | UzERMA8GA1UECAwITmVicmFza2ExDjAMBgNVBAcMBU9tYWhhMRowGAYDVQQKDBFu 4 | ZXRfdGNwX2NsaWVudF9jYTAgFw0xOTAxMDkxNTE5NTRaGA8yMTE4MTIxNjE1MTk1 5 | NFowXTELMAkGA1UEBhMCVVMxETAPBgNVBAgMCE5lYnJhc2thMQ4wDAYDVQQHDAVP 6 | bWFoYTEXMBUGA1UECgwObmV0X3RjcF9jbGllbnQxEjAQBgNVBAMMCWxvY2FsaG9z 7 | dDCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAK8k0edkqMK/MjmNbgYG 8 | r/Va1xem125YeCvmiKPi2ZOqownHL3f2fPp8eBxEeyN2aipxCVQxcICUyDCKyupp 9 | 6z9eQfJOGk/zeZlnSc8twW4VeqWueyGmXqyVGhWWK8C9H0iV/KOIiec+1kz5KfCf 10 | kB6rMh8HYuNoHAVuHhzbz3OOvXbMwVF3VeSFstJEcPymBAKs+e3f1ENO5GTYvHf3 11 | qMHNtPgjWDS9WLnW4fcxDcWvdHFcayJkOpgGlj/F/I2G022MrIc64aeOdzd5Rsrf 12 | WK2d+OyGCHckUIg+e2RL3fv0bElLDQ9e3CqKsbtZoy/QmVfDHOgD+S5GgCXjYcsz 13 | qhECAwEAATANBgkqhkiG9w0BAQUFAAOCAQEAT8ZE0WCAhkbwKt4csU8eTRaY3iXn 14 | bEDGaO+q+bVw4KpjmTdf6oIStmrA0v9vxUjB5Ghc8N7F1yQ0mSQ6IgtysFVG3zEp 15 | EUOwNY4EndOxNsTVhKzN/4GIoMNoogjptevkFMWkVMGOA0a1IqbT6Rga/GbGPF+6 16 | 16mgoLz8VfbOYn4SytifFR+8EGbeZxKSRtJFUtYg6sX4q9voQGAhfQXXiDDSXxnl 17 | qug3RxueccvPHvJj3Yn6GyLqVjYyRC9xxildEtKyO932x3wdW/LSnXh8MsfP/EYt 18 | hcDi0ydRutNYCAW+8ZisZdUnDADlA5PelacsaQTe8m3Nn97b+ASYnwyMSw== 19 | -----END CERTIFICATE----- 20 | -------------------------------------------------------------------------------- /test/ssl_files/no-sni-key.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN RSA PRIVATE KEY----- 2 | MIIEpQIBAAKCAQEAyLStTQ8EAeW+VtFFWLGjsTU5ChcjRnc1xlD91evX8Rvc5ZsS 3 | xkbW9zLv30jRjljSmpGZIQR/KRSKLbLfCDfcCCjybOZbu6oTqKFRJ0gb6qKcwIOE 4 | +ZaP6uulfbDnZiOoYIKALtBkXbepi36V6xBYfMcS+mpnRP0YHmxshX3iPH107YWE 5 | 2qev+2tRY4Do899d00+bKFThD4tI3rEvmVJKcer87xx9EyC8WLJhWud2+t2J1gea 6 | ZdpJVNzz3c0ex5vHT1pp6nK+B+H6DuIqT+MyOFuln++q5u6AEOu08tp2sPgTwXtx 7 | powwauq7FVWAhZSjrlr6VMzMNfLAEBYE6bFA9QIDAQABAoIBAQC1bv8qpeRNgs4p 8 | 1UwG/a6oRyClCn2M+b7W4+hTNbwj7bgmp6S1MNyq4pUNF9q3/3uC1xPCUTpSfIrc 9 | /NG5sCVsCvf7kdJjN0BGNG4UQI9b8Fwbe8j9hynah+M2WHEWWC2h8NbHewL/5UOT 10 | In+L217ijWOOlBl+t/zRo9oGYuHdI0JWKfJca99/Jcqrjhrj1pdViDszbiBjwOLV 11 | 0qXnhgDEXnjWSXYVNar0lSgh8Rb8wGsgrypyfoXg832oN3C26pugugH/SR7d6oaD 12 | OY0ySPfsIZi/V2iRkxIJtp6yECf3J4lNBrG2STjcHhZck+HuB/vz5Yk1qQqcfO6X 13 | ObuYrbKBAoGBAOiw+615kwEFWEuCAtWgZzUMrWViO45HbmLGePOBY/nVX/3kAKrb 14 | yhjYgMlzH3ITQUkTNdOYtZ0ZudkIfTITJ+bZK2EhXVgTd7Qi5YsI+fyUqfNf2nWK 15 | mvRtKl12uN5Y7OtVeHrkrrrlN+a1aki+cdH2qSD3MwrtKs+JfKLff9QtAoGBANzP 16 | eOrmgdFcjFoaJcak0iVNG8kke8stUzrc9lAiUbvZ7F8kQ9DW0rkYuoAbzV+SLQcu 17 | hrbFf9RQ4AM5VSP4awQjv0Lj5UTgYF4GrHjsauKeEfMY96txmuW5C/3T1mYitLdl 18 | fs/76S5+oj46/VI2/iIOYxiqLaO9EqEwgP/sQDTpAoGAUHyvgZDU7Xx4zx14d4ZV 19 | TL9G1xPEf/FrWFVIjwoJl+hbnMmaBX+jBzcUTRo6HU5Vvb4cV0WyRFYat9y82W5Q 20 | 1gP2glF1JTsOo8uSVKZVOi3+H0XfndrEwJlmFxAy4A4oXTqiQvgJDHKvBGlqCyF8 21 | 42CLnfCDwlrI/SKUbw4Z/D0CgYEAoTVb22uU5axCz9l9MOzOe+sy2QQo2SprNHNz 22 | 5QdZUuOEfeW1GThtujNCnhsuMpM/Cpo+Qhwo+nJdSh1Geq94Ohp7HbPShBmoYZ1P 23 | uC0qz+6FvkzBLUsQwpz6E0PgqMq305lnHyOUl5xeiT56CdcabPTCBpTgI0X73vDR 24 | jYcHTVkCgYEA1Uj47vC8jV1mBtzIKFfvo4W1tkAm1aSDOApzg0N7JEjvgHbeBqM6 25 | i8dkJ3PgcqxIQ1np+0QTDVjbLgNqCsribJ/djbPDhH/LW9vPP5uUwm10qa7UOJ09 26 | Jrc3b7r+ZQHPjr7upj5fUK1Xcs0oY7yHMuc8oLDNcdDIYqxP/RzjcOY= 27 | -----END RSA PRIVATE KEY----- 28 | -------------------------------------------------------------------------------- /test/ssl_files/no-sni.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIIDQTCCAikCCQDRgr9ZEAyEkjANBgkqhkiG9w0BAQUFADBMMQswCQYDVQQGEwJV 3 | UzERMA8GA1UECAwITmVicmFza2ExDjAMBgNVBAcMBU9tYWhhMRowGAYDVQQKDBFu 4 | ZXRfdGNwX2NsaWVudF9jYTAgFw0xOTAxMDkxNTIwMDhaGA8yMTE4MTIxNjE1MjAw 5 | OFowdzELMAkGA1UEBhMCVVMxETAPBgNVBAgMCE5lYnJhc2thMQ4wDAYDVQQHDAVP 6 | bWFoYTEXMBUGA1UECgwObmV0X3RjcF9jbGllbnQxLDAqBgNVBAMMI25vLWhvc3Ru 7 | YW1lLXdhcy1naXZlbi1mb3Itc25pbmkuY29tMIIBIjANBgkqhkiG9w0BAQEFAAOC 8 | AQ8AMIIBCgKCAQEAyLStTQ8EAeW+VtFFWLGjsTU5ChcjRnc1xlD91evX8Rvc5ZsS 9 | xkbW9zLv30jRjljSmpGZIQR/KRSKLbLfCDfcCCjybOZbu6oTqKFRJ0gb6qKcwIOE 10 | +ZaP6uulfbDnZiOoYIKALtBkXbepi36V6xBYfMcS+mpnRP0YHmxshX3iPH107YWE 11 | 2qev+2tRY4Do899d00+bKFThD4tI3rEvmVJKcer87xx9EyC8WLJhWud2+t2J1gea 12 | ZdpJVNzz3c0ex5vHT1pp6nK+B+H6DuIqT+MyOFuln++q5u6AEOu08tp2sPgTwXtx 13 | powwauq7FVWAhZSjrlr6VMzMNfLAEBYE6bFA9QIDAQABMA0GCSqGSIb3DQEBBQUA 14 | A4IBAQB6hpp37TLk0+Pd9+gP/OE/7s9AEfcFSelTJZguDaDO/LqHxEXbokOKcQi5 15 | g0vGxc/XK7RYdw+eEXzbXazQUTPQ6JAfpH0bVXfTvaFKMefHdcnB0NySnnHl2BfQ 16 | 5cFjqHQwBV6jVIH0q125rC3F1aczDo5kyLk3UyMenY8zoZt8baacChvSqUXDBiSn 17 | /04Pof//O3ky2B3DYyunQJZ1mQNs+FJdO5609BmlPvaoZfiFbMxGQcmV4D+0mQm+ 18 | CzxWZ4p8gDPDIO4mzVvDMbrRksg1CdG6WakuX1Vy94pX4JNiJ7e47lvuoWIL6DFp 19 | 5mWIV72Ra4bU5ybNGZlrxLg/6ueD 20 | -----END CERTIFICATE----- 21 | -------------------------------------------------------------------------------- /test/tcp_client_test.rb: -------------------------------------------------------------------------------- 1 | require "socket" 2 | require_relative "test_helper" 3 | require_relative "simple_tcp_server" 4 | require "securerandom" 5 | 6 | # Unit Test for Net::TCPClient 7 | class TCPClientTest < Minitest::Test 8 | describe Net::TCPClient do 9 | [false, true].each do |with_ssl| 10 | describe(with_ssl ? "with ssl" : "without ssl") do 11 | describe "#connect" do 12 | it "raises an exception when cannot reach server after 5 retries" do 13 | exception = assert_raises Net::TCPClient::ConnectionFailure do 14 | new_net_tcp_client( 15 | with_ssl, 16 | server: "localhost:3300", 17 | connect_retry_interval: 0.1, 18 | connect_retry_count: 5 19 | ) 20 | end 21 | assert_match(/Connection failure connecting to/, exception.message) 22 | assert_match Errno::ECONNREFUSED.to_s, exception.cause.class.to_s 23 | end 24 | 25 | it "times out on connect" do 26 | skip("When not using SSL it will often connect anyway. Maybe a better way to test non-ssl?") unless with_ssl 27 | 28 | # Create a TCP Server, but do not respond to connections to cause a connect timeout 29 | server = TCPServer.open(2094) 30 | sleep 1 31 | 32 | exception = assert_raises Net::TCPClient::ConnectionTimeout do 33 | new_net_tcp_client( 34 | with_ssl, 35 | server: "localhost:2094", 36 | connect_timeout: 0.5, 37 | connect_retry_count: 3 38 | ) 39 | end 40 | assert_match(/Timed out after 0\.5 seconds/, exception.message) 41 | server.close 42 | end 43 | end 44 | 45 | describe "with server" do 46 | before do 47 | @port = 2000 + SecureRandom.random_number(1000) 48 | options = {port: @port} 49 | if with_ssl 50 | options[:ssl] = { 51 | # Purposefully serve a cert that doesn't match 'localhost' to force failures unless SNI works. 52 | cert: OpenSSL::X509::Certificate.new(File.open(ssl_file_path("no-sni.pem"))), 53 | key: OpenSSL::PKey::RSA.new(File.open(ssl_file_path("no-sni-key.pem"))), 54 | ca_file: ssl_file_path("ca.pem") 55 | } 56 | end 57 | count = 0 58 | begin 59 | @server = SimpleTCPServer.new(options) 60 | rescue Errno::EADDRINUSE => e 61 | @server&.stop 62 | # Give previous test server time to stop 63 | count += 1 64 | sleep 1 65 | retry if count <= 30 66 | raise e 67 | end 68 | 69 | @server_name = "localhost:#{@port}" 70 | end 71 | 72 | after do 73 | @client&.close 74 | @server&.stop 75 | end 76 | 77 | describe "#read" do 78 | it "read timeout, followed by successful read" do 79 | @read_timeout = 3.0 80 | # Need a custom client that does not auto close on error: 81 | @client = new_net_tcp_client( 82 | with_ssl, 83 | server: @server_name, 84 | read_timeout: @read_timeout, 85 | close_on_error: false 86 | ) 87 | 88 | request = {"action" => "sleep", "duration" => @read_timeout + 0.5} 89 | @client.write(request.to_bson) 90 | 91 | exception = assert_raises Net::TCPClient::ReadTimeout do 92 | # Read 4 bytes from server 93 | @client.read(4) 94 | end 95 | assert_equal false, @client.close_on_error 96 | assert @client.alive?, "The client connection is not alive after the read timed out with close_on_error: false" 97 | assert_equal "Timed out after #{@read_timeout} seconds trying to read from localhost[127.0.0.1]:#{@port}", exception.message 98 | reply = read_bson_document(@client) 99 | assert_equal "sleep", reply["result"] 100 | @client.close 101 | end 102 | 103 | it "infinite timeout" do 104 | @client = new_net_tcp_client( 105 | with_ssl, 106 | server: @server_name, 107 | connect_timeout: -1 108 | ) 109 | request = {"action" => "test1"} 110 | @client.write(request.to_bson) 111 | reply = read_bson_document(@client) 112 | assert_equal "test1", reply["result"] 113 | @client.close 114 | end 115 | end 116 | 117 | describe "#connect" do 118 | it "calls on_connect after connection" do 119 | @client = new_net_tcp_client( 120 | with_ssl, 121 | server: @server_name, 122 | read_timeout: 3, 123 | on_connect: proc do |socket| 124 | # Reset user_data on each connection 125 | socket.user_data = {sequence: 1} 126 | end 127 | ) 128 | assert_equal "localhost[127.0.0.1]:#{@port}", @client.address.to_s 129 | assert_equal 1, @client.user_data[:sequence] 130 | 131 | request = {"action" => "test1"} 132 | @client.write(request.to_bson) 133 | reply = read_bson_document(@client) 134 | assert_equal "test1", reply["result"] 135 | end 136 | end 137 | 138 | describe "failover" do 139 | it "connects to second server when the first is down" do 140 | @client = new_net_tcp_client( 141 | with_ssl, 142 | servers: ["localhost:1999", @server_name], 143 | read_timeout: 3 144 | ) 145 | assert_equal "localhost[127.0.0.1]:#{@port}", @client.address.to_s 146 | 147 | request = {"action" => "test1"} 148 | @client.write(request.to_bson) 149 | reply = read_bson_document(@client) 150 | assert_equal "test1", reply["result"] 151 | end 152 | end 153 | 154 | describe "with client" do 155 | before do 156 | @read_timeout = 3.0 157 | @client = new_net_tcp_client( 158 | with_ssl, 159 | server: @server_name, 160 | read_timeout: @read_timeout 161 | ) 162 | assert @client.alive?, @client.ai 163 | assert_equal true, @client.close_on_error 164 | end 165 | 166 | describe "#alive?" do 167 | it "returns false once the connection is closed" do 168 | skip "TODO: #alive? hangs with the latest SSL changes" if with_ssl 169 | assert @client.alive? 170 | @client.close 171 | refute @client.alive? 172 | end 173 | end 174 | 175 | describe "#closed?" do 176 | it "returns true once the connection is closed" do 177 | refute @client.closed? 178 | @client.close 179 | assert @client.closed? 180 | end 181 | end 182 | 183 | describe "#close" do 184 | it "closes the connection, repeatedly without error" do 185 | @client.close 186 | @client.close 187 | end 188 | end 189 | 190 | describe "#write" do 191 | it "writes data" do 192 | request = {"action" => "test1"} 193 | @client.write(request.to_bson) 194 | end 195 | end 196 | 197 | describe "#read" do 198 | it "reads a response" do 199 | request = {"action" => "test1"} 200 | @client.write(request.to_bson) 201 | reply = read_bson_document(@client) 202 | assert_equal "test1", reply["result"] 203 | end 204 | 205 | it "times out on receive" do 206 | request = {"action" => "sleep", "duration" => @read_timeout + 0.5} 207 | @client.write(request.to_bson) 208 | 209 | exception = assert_raises Net::TCPClient::ReadTimeout do 210 | # Read 4 bytes from server 211 | @client.read(4) 212 | end 213 | # Due to close_on_error: true, a timeout will close the connection 214 | # to prevent use of a socket connection in an inconsistent state 215 | assert_equal false, @client.alive? 216 | assert_equal "Timed out after #{@read_timeout} seconds trying to read from localhost[127.0.0.1]:#{@port}", exception.message 217 | end 218 | end 219 | 220 | describe "#retry_on_connection_failure" do 221 | it "retries on connection failure" do 222 | attempt = 0 223 | reply = @client.retry_on_connection_failure do 224 | request = {"action" => "fail", "attempt" => (attempt += 1)} 225 | @client.write(request.to_bson) 226 | read_bson_document(@client) 227 | end 228 | assert_equal "fail", reply["result"] 229 | end 230 | end 231 | end 232 | end 233 | end 234 | end 235 | 236 | def ssl_file_path(name) 237 | File.join(File.dirname(__FILE__), "ssl_files", name) 238 | end 239 | 240 | def new_net_tcp_client(with_ssl, params) 241 | params = params.dup 242 | if with_ssl 243 | params.merge!( 244 | ssl: { 245 | ca_file: ssl_file_path("ca.pem"), 246 | verify_mode: OpenSSL::SSL::VERIFY_PEER 247 | } 248 | ) 249 | end 250 | Net::TCPClient.new(**params) 251 | end 252 | end 253 | end 254 | -------------------------------------------------------------------------------- /test/test_helper.rb: -------------------------------------------------------------------------------- 1 | # Allow test to be run in-place without requiring a gem install 2 | $LOAD_PATH.unshift "#{File.dirname(__FILE__)}/../lib" 3 | 4 | # Configure Rails Environment 5 | ENV["RAILS_ENV"] = "test" 6 | 7 | require "minitest/autorun" 8 | require "minitest/reporters" 9 | require "net/tcp_client" 10 | require "amazing_print" 11 | 12 | SemanticLogger.default_level = :trace 13 | SemanticLogger.add_appender(file_name: "test.log", formatter: :color) 14 | 15 | reporters = [ 16 | Minitest::Reporters::SpecReporter.new 17 | # SemanticLogger::Reporters::Minitest.new 18 | ] 19 | Minitest::Reporters.use!(reporters) 20 | --------------------------------------------------------------------------------