tcp-client 0.7.0 → 0.9.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,76 +1,153 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  class TCPClient
4
+ #
5
+ # Raised when a SSL connection should be establshed but the OpenSSL gem is not available.
6
+ #
4
7
  class NoOpenSSLError < RuntimeError
5
8
  def initialize
6
9
  super('OpenSSL is not available')
7
10
  end
8
11
  end
9
12
 
13
+ #
14
+ # Raised when a method requires a callback block but no such block is specified.
15
+ #
10
16
  class NoBlockGivenError < ArgumentError
11
17
  def initialize
12
18
  super('no block given')
13
19
  end
14
20
  end
15
21
 
22
+ #
23
+ # Raised when an invalid timeout value was specified.
24
+ #
16
25
  class InvalidDeadLineError < ArgumentError
26
+ #
27
+ # @param timeout [Object] the invalid value
28
+ #
17
29
  def initialize(timeout)
18
30
  super("invalid deadline - #{timeout}")
19
31
  end
20
32
  end
21
33
 
34
+ #
35
+ # Raised by {Configuration} when an undefined attribute should be set.
36
+ #
22
37
  class UnknownAttributeError < ArgumentError
38
+ #
39
+ # @param attribute [Object] the undefined atttribute
40
+ #
23
41
  def initialize(attribute)
24
42
  super("unknown attribute - #{attribute}")
25
43
  end
26
44
  end
27
45
 
46
+ #
47
+ # Raised when a given timeout exception parameter is not an exception class.
48
+ #
28
49
  class NotAnExceptionError < TypeError
50
+ #
51
+ # @param object [Object] the invalid object
52
+ #
29
53
  def initialize(object)
30
54
  super("exception class required - #{object.inspect}")
31
55
  end
32
56
  end
33
57
 
34
- class NotConnectedError < IOError
58
+ #
59
+ # Base exception class for all network related errors.
60
+ #
61
+ # Will be raised for any system level network error when {Configuration.normalize_network_errors} is configured.
62
+ #
63
+ # You should catch this exception class when you like to handle any relevant {TCPClient} error.
64
+ #
65
+ class NetworkError < StandardError
66
+ end
67
+
68
+ #
69
+ # Raised when a {TCPClient} instance should read/write from/to the network but is not connected.
70
+ #
71
+ class NotConnectedError < NetworkError
35
72
  def initialize
36
73
  super('client not connected')
37
74
  end
38
75
  end
39
76
 
40
- class TimeoutError < IOError
77
+ #
78
+ # Base exception class for a detected timeout.
79
+ #
80
+ # You should catch this exception class when you like to handle any timeout error.
81
+ #
82
+ class TimeoutError < NetworkError
83
+ #
84
+ # Initializes the instance with an optional message.
85
+ #
86
+ # The message will be generated from {#action} when not specified.
87
+ #
88
+ # @overload initialize
89
+ # @overload initialize(message)
90
+ #
91
+ # @param message [#to_s] the error message
92
+ #
41
93
  def initialize(message = nil)
42
94
  super(message || "unable to #{action} in time")
43
95
  end
44
96
 
97
+ #
98
+ # @attribute [r] action
99
+ # @return [Symbol] the action which timed out
100
+ #
45
101
  def action
46
102
  :process
47
103
  end
48
104
  end
49
105
 
106
+ #
107
+ # Raised by default whenever a {TCPClient.connect} timed out.
108
+ #
50
109
  class ConnectTimeoutError < TimeoutError
110
+ #
111
+ # @attribute [r] action
112
+ # @return [Symbol] the action which timed out: `:connect`
113
+ #
51
114
  def action
52
115
  :connect
53
116
  end
54
117
  end
55
118
 
119
+ #
120
+ # Raised by default whenever a {TCPClient#read} timed out.
121
+ #
56
122
  class ReadTimeoutError < TimeoutError
123
+ #
124
+ # @attribute [r] action
125
+ # @return [Symbol] the action which timed out: :read`
126
+ #
57
127
  def action
58
128
  :read
59
129
  end
60
130
  end
61
131
 
132
+ #
133
+ # Raised by default whenever a {TCPClient#write} timed out.
134
+ #
62
135
  class WriteTimeoutError < TimeoutError
136
+ #
137
+ # @attribute [r] action
138
+ # @return [Symbol] the action which timed out: `:write`
139
+ #
63
140
  def action
64
141
  :write
65
142
  end
66
143
  end
67
144
 
68
- NoOpenSSL = NoOpenSSLError
69
- NoBlockGiven = NoBlockGivenError
70
- InvalidDeadLine = InvalidDeadLineError
71
- UnknownAttribute = UnknownAttributeError
72
- NotAnException = NotAnExceptionError
73
- NotConnected = NotConnectedError
145
+ NoOpenSSL = NoOpenSSLError # @!visibility private
146
+ NoBlockGiven = NoBlockGivenError # @!visibility private
147
+ InvalidDeadLine = InvalidDeadLineError # @!visibility private
148
+ UnknownAttribute = UnknownAttributeError # @!visibility private
149
+ NotAnException = NotAnExceptionError # @!visibility private
150
+ NotConnected = NotConnectedError # @!visibility private
74
151
  deprecate_constant(
75
152
  :NoOpenSSL,
76
153
  :NoBlockGiven,
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- module IOWithDeadlineMixin
3
+ # @!visibility private
4
+ module IOWithDeadlineMixin # :nodoc:
4
5
  def self.included(mod)
5
6
  methods = mod.instance_methods
6
7
  if methods.index(:wait_writable) && methods.index(:wait_readable)
@@ -18,6 +18,7 @@ class TCPClient
18
18
  super(socket, create_context(ssl_params))
19
19
  self.sync_close = true
20
20
  self.hostname = address.hostname
21
+ check_new_session if @new_session
21
22
  deadline.valid? ? connect_with_deadline(deadline, exception) : connect
22
23
  post_connection_check(address.hostname) if should_verify?(ssl_params)
23
24
  end
@@ -25,7 +26,19 @@ class TCPClient
25
26
  private
26
27
 
27
28
  def create_context(ssl_params)
28
- OpenSSL::SSL::SSLContext.new.tap { |ctx| ctx.set_params(ssl_params) }
29
+ @new_session = nil
30
+ ::OpenSSL::SSL::SSLContext.new.tap do |ctx|
31
+ ctx.set_params(ssl_params)
32
+ ctx.session_cache_mode = CONTEXT_CACHE_MODE
33
+ ctx.session_new_cb = proc { |_, sess| @new_session = sess }
34
+ end
35
+ end
36
+
37
+ def check_new_session
38
+ time = @new_session.time.to_f + @new_session.timeout
39
+ if Process.clock_gettime(Process::CLOCK_REALTIME) < time
40
+ self.session = @new_session
41
+ end
29
42
  end
30
43
 
31
44
  def connect_with_deadline(deadline, exception)
@@ -33,9 +46,13 @@ class TCPClient
33
46
  end
34
47
 
35
48
  def should_verify?(ssl_params)
36
- ssl_params[:verify_mode] != OpenSSL::SSL::VERIFY_NONE &&
49
+ ssl_params[:verify_mode] != ::OpenSSL::SSL::VERIFY_NONE &&
37
50
  context.verify_hostname
38
51
  end
52
+
53
+ CONTEXT_CACHE_MODE =
54
+ ::OpenSSL::SSL::SSLContext::SESSION_CACHE_CLIENT |
55
+ ::OpenSSL::SSL::SSLContext::SESSION_CACHE_NO_INTERNAL_STORE
39
56
  end
40
57
 
41
58
  private_constant(:SSLSocket)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  class TCPClient
4
- VERSION = '0.7.0'
4
+ VERSION = '0.9.2'
5
5
  end
data/lib/tcp-client.rb CHANGED
@@ -9,8 +9,48 @@ require_relative 'tcp-client/configuration'
9
9
  require_relative 'tcp-client/default_configuration'
10
10
  require_relative 'tcp-client/version'
11
11
 
12
+ #
13
+ # Client class to communicate with a server via TCP w/o SSL.
14
+ #
15
+ # All connect/read/write actions can be monitored to ensure that all actions
16
+ # terminate before given time limits - or raise an exception.
17
+ #
18
+ # @example request to Google.com and limit network interactions to 1.5 seconds
19
+ # TCPClient.with_deadline(1.5, 'www.google.com:443') do |client|
20
+ # client.write("GET / HTTP/1.1\r\nHost: www.google.com\r\n\r\n")
21
+ # client.read(12)
22
+ # end
23
+ # # => "HTTP/1.1 200"
24
+ #
25
+ #
12
26
  class TCPClient
13
- def self.open(address, configuration = Configuration.default)
27
+ #
28
+ # Creates a new instance which is connected to the server on the given
29
+ # `address`.
30
+ #
31
+ # If no `configuration` is given, the {.default_configuration} will be used.
32
+ #
33
+ # If an optional block is given, then the block's result is returned and the
34
+ # connection will be closed when the block execution ends.
35
+ # This can be used to create an ad-hoc connection which is garanteed to be
36
+ # closed.
37
+ #
38
+ # If no block is giiven the connected client instance is returned.
39
+ # This can be used as a shorthand to create & connect a client.
40
+ #
41
+ # @param address [Address, String, Addrinfo, Integer] the address to connect
42
+ # to, see {Address#initialize} for valid formats
43
+ # @param configuration [Configuration] the {Configuration} to be used for
44
+ # this instance
45
+ #
46
+ # @yieldparam client [TCPClient] the connected client
47
+ # @yieldreturn [Object] any result
48
+ #
49
+ # @return [Object, TCPClient] the block result or the connected client
50
+ #
51
+ # @see #connect
52
+ #
53
+ def self.open(address, configuration = nil)
14
54
  client = new
15
55
  client.connect(Address.new(address), configuration)
16
56
  block_given? ? yield(client) : client
@@ -18,11 +58,35 @@ class TCPClient
18
58
  client.close if block_given?
19
59
  end
20
60
 
21
- def self.with_deadline(
22
- timeout,
23
- address,
24
- configuration = Configuration.default
25
- )
61
+ #
62
+ # Yields a new instance which is connected to the server on the given
63
+ # `address`.It limits all {#read} and {#write} actions within the block to
64
+ # the given time.
65
+ #
66
+ # It ensures to close the connection when the block execution ends and returns
67
+ # the block`s result.
68
+ #
69
+ # This can be used to create an ad-hoc connection which is garanteed to be
70
+ # closed and which {#read}/{#write} call sequence should not last longer than
71
+ # the `timeout`.
72
+ #
73
+ # If no `configuration` is given, the {.default_configuration} will be used.
74
+ #
75
+ # @param timeout [Numeric] maximum time in seconds for all {#read} and
76
+ # {#write} calls within the block
77
+ # @param address [Address, String, Addrinfo, Integer] the address to connect
78
+ # to, see {Address#initialize} for valid formats
79
+ # @param configuration [Configuration] the {Configuration} to be used for
80
+ # this instance
81
+ #
82
+ # @yieldparam client [TCPClient] the connected client
83
+ # @yieldreturn [Object] any result
84
+ #
85
+ # @return [Object] the block's result
86
+ #
87
+ # @see #with_deadline
88
+ #
89
+ def self.with_deadline(timeout, address, configuration = nil)
26
90
  client = nil
27
91
  raise(NoBlockGivenError) unless block_given?
28
92
  address = Address.new(address)
@@ -34,38 +98,142 @@ class TCPClient
34
98
  client&.close
35
99
  end
36
100
 
37
- attr_reader :address, :configuration
101
+ #
102
+ # @return [Address] the address used by this client instance
103
+ #
104
+ attr_reader :address
38
105
 
39
- def initialize
40
- @socket = @address = @deadline = @configuration = nil
106
+ #
107
+ # @return [Configuration] the configuration used by this client instance
108
+ #
109
+ attr_reader :configuration
110
+
111
+ #
112
+ # @!parse attr_reader :closed?
113
+ # @return [Boolean] true when the connection is closed, false when connected
114
+ #
115
+ def closed?
116
+ @socket.nil? || @socket.closed?
41
117
  end
42
118
 
43
- def to_s
44
- @address&.to_s || ''
119
+ #
120
+ # Close the current connection if connected.
121
+ #
122
+ # @return [self]
123
+ #
124
+ def close
125
+ @socket&.close
126
+ self
127
+ rescue *NETWORK_ERRORS
128
+ self
129
+ ensure
130
+ @socket = @deadline = nil
45
131
  end
46
132
 
47
- def connect(address, configuration, timeout: nil, exception: nil)
133
+ #
134
+ # Establishes a new connection to a given `address`.
135
+ #
136
+ # It accepts a connection-specific configuration or uses the
137
+ # {.default_configuration}. The {#configuration} used by this instance will
138
+ # be a copy of the configuration used for this method call. This allows to
139
+ # configure the behavior per connection.
140
+ #
141
+ # The optional `timeout` and `exception` parameters allow to override the
142
+ # `connect_timeout` and `connect_timeout_error` values.
143
+ #
144
+ # @param address [Address, String, Addrinfo, Integer] the address to connect
145
+ # to, see {Address#initialize} for valid formats
146
+ # @param configuration [Configuration] the {Configuration} to be used for
147
+ # this instance
148
+ # @param timeout [Numeric] maximum time in seconds to connect
149
+ # @param exception [Class] exception class to be used when the connect timeout
150
+ # reached
151
+ #
152
+ # @return [self]
153
+ #
154
+ # @raise {NoOpenSSLError} if SSL should be used but OpenSSL is not avail
155
+ #
156
+ # @see NetworkError
157
+ #
158
+ def connect(address, configuration = nil, timeout: nil, exception: nil)
48
159
  close if @socket
49
- raise(NoOpenSSLError) if configuration.ssl? && !defined?(SSLSocket)
50
160
  @address = Address.new(address)
51
- @configuration = configuration.dup.freeze
161
+ @configuration = (configuration || Configuration.default).dup
162
+ raise(NoOpenSSLError) if @configuration.ssl? && !defined?(SSLSocket)
52
163
  @socket = create_socket(timeout, exception)
53
164
  self
54
165
  end
55
166
 
56
- def close
57
- @socket&.close
58
- self
59
- rescue IOError
167
+ #
168
+ # Flush all internal buffers (write all through).
169
+ #
170
+ # @return [self]
171
+ #
172
+ def flush
173
+ stem_errors { @socket&.flush }
60
174
  self
61
- ensure
62
- @socket = @deadline = nil
63
175
  end
64
176
 
65
- def closed?
66
- @socket.nil? || @socket.closed?
177
+ #
178
+ # Read the given `nbytes` or the next available buffer from server.
179
+ #
180
+ # The optional `timeout` and `exception` parameters allow to override the
181
+ # `read_timeout` and `read_timeout_error` values of the used {#configuration}.
182
+ #
183
+ # @param nbytes [Integer] the number of bytes to read
184
+ # @param timeout [Numeric] maximum time in seconds to read
185
+ # @param exception [Class] exception class to be used when the read timeout
186
+ # reached
187
+ #
188
+ # @return [String] the read buffer
189
+ #
190
+ # @raise [NotConnectedError] if {#connect} was not called before
191
+ #
192
+ # @see NetworkError
193
+ #
194
+ def read(nbytes = nil, timeout: nil, exception: nil)
195
+ raise(NotConnectedError) if closed?
196
+ deadline = create_deadline(timeout, configuration.read_timeout)
197
+ return stem_errors { @socket.read(nbytes) } unless deadline.valid?
198
+ exception ||= configuration.read_timeout_error
199
+ stem_errors(exception) do
200
+ @socket.read_with_deadline(nbytes, deadline, exception)
201
+ end
67
202
  end
68
203
 
204
+ #
205
+ # @return [String] the currently used address as text.
206
+ #
207
+ # @see Address#to_s
208
+ #
209
+ def to_s
210
+ @address&.to_s || ''
211
+ end
212
+
213
+ #
214
+ # Execute a block with a given overall time limit.
215
+ #
216
+ # When you like to ensure that a complete {#read}/{#write} communication
217
+ # sequence with the server is finished before a given amount of time you use
218
+ # this method.
219
+ #
220
+ # @example ensure to send SMTP welcome message and receive a 4 byte answer
221
+ # answer = client.with_deadline(2.5) do
222
+ # client.write('HELO')
223
+ # client.read(4)
224
+ # end
225
+ # # answer is EHLO when server speaks fluent SMPT
226
+ #
227
+ # @param timeout [Numeric] maximum time in seconds for all {#read} and
228
+ # {#write} calls within the block
229
+ #
230
+ # @yieldparam client [TCPClient] self
231
+ # @yieldreturn [Object] any result
232
+ #
233
+ # @return [Object] the block`s result
234
+ #
235
+ # @raise [NoBlockGivenError] if the block is missing
236
+ #
69
237
  def with_deadline(timeout)
70
238
  previous_deadline = @deadline
71
239
  raise(NoBlockGivenError) unless block_given?
@@ -76,29 +244,34 @@ class TCPClient
76
244
  @deadline = previous_deadline
77
245
  end
78
246
 
79
- def read(nbytes = nil, timeout: nil, exception: nil)
80
- raise(NotConnectedError) if closed?
81
- deadline = create_deadline(timeout, configuration.read_timeout)
82
- return @socket.read(nbytes) unless deadline.valid?
83
- exception ||= configuration.read_timeout_error
84
- @socket.read_with_deadline(nbytes, deadline, exception)
85
- end
86
-
87
- def write(*msg, timeout: nil, exception: nil)
247
+ #
248
+ # Write the given `messages` to the server.
249
+ #
250
+ # The optional `timeout` and `exception` parameters allow to override the
251
+ # `write_timeout` and `write_timeout_error` values of the used
252
+ # {#configuration}.
253
+ #
254
+ # @param messages [String] one or more messages to write
255
+ # @param timeout [Numeric] maximum time in seconds to write
256
+ # @param exception [Class] exception class to be used when the write timeout
257
+ # reached
258
+ #
259
+ # @return [Integer] bytes written
260
+ #
261
+ # @raise [NotConnectedError] if {#connect} was not called before
262
+ #
263
+ def write(*messages, timeout: nil, exception: nil)
88
264
  raise(NotConnectedError) if closed?
89
265
  deadline = create_deadline(timeout, configuration.write_timeout)
90
- return @socket.write(*msg) unless deadline.valid?
266
+ return stem_errors { @socket.write(*messages) } unless deadline.valid?
91
267
  exception ||= configuration.write_timeout_error
92
- msg.sum do |chunk|
93
- @socket.write_with_deadline(chunk.b, deadline, exception)
268
+ stem_errors(exception) do
269
+ messages.sum do |chunk|
270
+ @socket.write_with_deadline(chunk.b, deadline, exception)
271
+ end
94
272
  end
95
273
  end
96
274
 
97
- def flush
98
- @socket&.flush
99
- self
100
- end
101
-
102
275
  private
103
276
 
104
277
  def create_deadline(timeout, default)
@@ -108,8 +281,34 @@ class TCPClient
108
281
  def create_socket(timeout, exception)
109
282
  deadline = create_deadline(timeout, configuration.connect_timeout)
110
283
  exception ||= configuration.connect_timeout_error
111
- @socket = TCPSocket.new(address, configuration, deadline, exception)
112
- return @socket unless configuration.ssl?
113
- SSLSocket.new(@socket, address, configuration, deadline, exception)
284
+ stem_errors(exception) do
285
+ @socket = TCPSocket.new(address, configuration, deadline, exception)
286
+ return @socket unless configuration.ssl?
287
+ SSLSocket.new(@socket, address, configuration, deadline, exception)
288
+ end
114
289
  end
290
+
291
+ def stem_errors(except = nil)
292
+ yield
293
+ rescue *NETWORK_ERRORS => e
294
+ raise unless configuration.normalize_network_errors
295
+ (except && e.is_a?(except)) ? raise : raise(NetworkError, e)
296
+ end
297
+
298
+ NETWORK_ERRORS =
299
+ [
300
+ Errno::EADDRNOTAVAIL,
301
+ Errno::ECONNABORTED,
302
+ Errno::ECONNREFUSED,
303
+ Errno::ECONNRESET,
304
+ Errno::EHOSTUNREACH,
305
+ Errno::EINVAL,
306
+ Errno::ENETUNREACH,
307
+ Errno::EPIPE,
308
+ IOError,
309
+ SocketError
310
+ ].tap do |errors|
311
+ errors << ::OpenSSL::SSL::SSLError if defined?(::OpenSSL::SSL::SSLError)
312
+ end.freeze
313
+ private_constant(:NETWORK_ERRORS)
115
314
  end
data/rakefile.rb CHANGED
@@ -1,16 +1,12 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'rake/clean'
4
- require 'rake/testtask'
5
4
  require 'bundler/gem_tasks'
5
+ require 'rspec/core/rake_task'
6
+ require 'yard'
6
7
 
7
8
  $stdout.sync = $stderr.sync = true
8
-
9
- CLOBBER << 'prj'
10
-
9
+ CLOBBER << 'prj' << 'doc' << '.yardoc'
11
10
  task(:default) { exec('rake --tasks') }
12
-
13
- Rake::TestTask.new(:test) do |task|
14
- task.pattern = 'test/**/*_test.rb'
15
- task.warning = task.verbose = true
16
- end
11
+ RSpec::Core::RakeTask.new { |task| task.ruby_opts = %w[-w] }
12
+ YARD::Rake::YardocTask.new { |task| task.stats_options = %w[--list-undoc] }
data/sample/google_ssl.rb CHANGED
@@ -2,23 +2,24 @@
2
2
 
3
3
  require_relative '../lib/tcp-client'
4
4
 
5
- # create a configuration.
6
- # - use TLS 1.2
5
+ # create a configuration:
7
6
  # - don't use internal buffering
7
+ # - use TLS 1.2 or TLS 1.3
8
8
  cfg =
9
9
  TCPClient::Configuration.create(
10
10
  buffered: false,
11
11
  ssl_params: {
12
- ssl_version: :TLSv1_2
12
+ min_version: :TLS1_2,
13
+ max_version: :TLS1_3
13
14
  }
14
15
  )
15
16
 
16
- # request to Google:
17
- # - limit all interactions to 0.5 seconds
17
+ # request to Google.com:
18
+ # - limit all network interactions to 1.5 seconds
18
19
  # - use the Configuration cfg
19
20
  # - send a simple HTTP get request
20
21
  # - read 12 byte: "HTTP/1.1 " + 3 byte HTTP status code
21
- TCPClient.with_deadline(0.5, 'www.google.com:443', cfg) do |client|
22
+ TCPClient.with_deadline(1.5, 'www.google.com:443', cfg) do |client|
22
23
  p client.write("GET / HTTP/1.1\r\nHost: www.google.com\r\n\r\n")
23
24
  p client.read(12)
24
25
  end
data/spec/helper.rb ADDED
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rspec/core'
4
+ require_relative '../lib/tcp-client'
5
+
6
+ $stdout.sync = $stderr.sync = true
7
+
8
+ RSpec.configure do |config|
9
+ config.disable_monkey_patching!
10
+ config.warnings = true
11
+ config.order = :random
12
+ end