ld-celluloid-eventsource 0.10.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA1:
3
- metadata.gz: 8bd8fda8554cfa1dba5eb39bbf50070aa0d7106b
4
- data.tar.gz: a16e16201da0c651b4528b4f213230e0200b743e
3
+ metadata.gz: d2311a2b7704096d17f374aac47e599e87c69541
4
+ data.tar.gz: 03e6a782b9ae04bfc6f52da8958baa8344755a75
5
5
  SHA512:
6
- metadata.gz: d1d605c0cebbb138c600e8893760c7819237238c58cd20650c419cc9a79f98d3edea814698e25a627ad1767b2fe14e840696ae2de668dcaf10853a4709017dce
7
- data.tar.gz: 8771be4c7e0dafce34b50018d4d9719460940c15eb86ea059c6bc140d96dbc2e0e1db079308868e09e48dd8869d80a50b5904f8bd16d5dc003a460354a93f993
6
+ metadata.gz: 10e03560e84e12a05124523bbc0580dd4c72baaa67f59fb42c9747b7fbd485371a1d40a858f1627bc8ecba632e6e8742af0eee2b32f92c37dc9e0e507c689b28
7
+ data.tar.gz: 2bd535c2616ca1189558078d658e69ccafd05f68cc5286fc9298a07c5a14142bf9d2d8938952a569d032d4d5d8be373127176b251e89c060b72a4e2fe785d0f0
@@ -20,7 +20,7 @@ Gem::Specification.new do |spec|
20
20
 
21
21
  spec.add_dependency 'celluloid-io', '~> 0.17.3'
22
22
  spec.add_dependency 'celluloid', '~> 0.18.0.pre'
23
- spec.add_dependency 'nio4r', '~> 1.1'
23
+ spec.add_dependency 'nio4r', '>= 1.1'
24
24
  spec.add_dependency 'http_parser.rb', '~> 0.6.0'
25
25
 
26
26
  spec.add_development_dependency 'atomic', '~> 1.1'
@@ -1,6 +1,7 @@
1
1
  require 'celluloid/current'
2
2
  require 'celluloid/eventsource/version'
3
3
  require 'celluloid/io'
4
+ require 'celluloid/eventsource/event_parser'
4
5
  require 'celluloid/eventsource/response_parser'
5
6
  require 'uri'
6
7
  require 'base64'
@@ -8,8 +9,15 @@ require 'base64'
8
9
  module Celluloid
9
10
  class EventSource
10
11
  include Celluloid::IO
12
+ include Celluloid::Internals::Logger
11
13
  Celluloid.boot
12
14
 
15
+ class UnexpectedContentType < StandardError
16
+ end
17
+
18
+ class ReadTimeout < StandardError
19
+ end
20
+
13
21
  attr_reader :url, :with_credentials
14
22
  attr_reader :ready_state
15
23
 
@@ -17,14 +25,28 @@ module Celluloid
17
25
  OPEN = 1
18
26
  CLOSED = 2
19
27
 
28
+ MAX_RECONNECT_TIME = 30
29
+
20
30
  execute_block_on_receiver :initialize
21
31
 
32
+ #
33
+ # Constructor for an EventSource.
34
+ #
35
+ # @param uri [String] the event stream URI
36
+ # @param opts [Hash] the configuration options
37
+ # @option opts [Hash] :headers Headers to send with the request
38
+ # @option opts [Float] :read_timeout Timeout (in seconds) after which to restart the connection if
39
+ # the server has sent no data
40
+ # @option opts [Float] :reconnect_delay Initial delay (in seconds) between connection attempts; this will
41
+ # be increased exponentially if there are repeated failures
42
+ #
22
43
  def initialize(uri, options = {})
23
44
  self.url = uri
24
45
  options = options.dup
25
46
  @ready_state = CONNECTING
26
47
  @with_credentials = options.delete(:with_credentials) { false }
27
48
  @headers = default_request_headers.merge(options.fetch(:headers, {}))
49
+ @read_timeout = options.fetch(:read_timeout, 0).to_i
28
50
  proxy = ENV['HTTP_PROXY'] || ENV['http_proxy'] || options[:proxy]
29
51
  if proxy
30
52
  proxyUri = URI(proxy)
@@ -33,15 +55,8 @@ module Celluloid
33
55
  end
34
56
  end
35
57
 
36
- @event_type_buffer = ""
37
- @last_event_id_buffer = ""
38
- @data_buffer = ""
39
-
40
- @last_event_id = String.new
41
-
42
- @reconnect_timeout = 1
58
+ @reconnect_timeout = options.fetch(:reconnect_delay, 1)
43
59
  @on = { open: ->{}, message: ->(_) {}, error: ->(_) {} }
44
- @parser = ResponseParser.new
45
60
 
46
61
  @chunked = false
47
62
 
@@ -66,11 +81,13 @@ module Celluloid
66
81
  while !closed?
67
82
  begin
68
83
  establish_connection
69
- chunked? ? process_chunked_stream : process_stream
70
- rescue
71
- # Just reconnect
84
+ process_stream
85
+ rescue UnexpectedContentType
86
+ raise # Let these flow to the top
87
+ rescue StandardError => e
88
+ info "Reconnecting after exception: #{e}"
89
+ # Just reconnect on runtime errors
72
90
  end
73
- sleep @reconnect_timeout
74
91
  end
75
92
  end
76
93
 
@@ -97,54 +114,79 @@ module Celluloid
97
114
 
98
115
  private
99
116
 
100
- MessageEvent = Struct.new(:type, :data, :last_event_id)
101
-
102
117
  def ssl?
103
118
  url.scheme == 'https'
104
119
  end
105
120
 
106
121
  def establish_connection
107
- if @proxy
108
- sock = ::TCPSocket.new(@proxy.host, @proxy.port)
109
- @socket = Celluloid::IO::TCPSocket.new(sock)
110
-
111
- @socket.write(connect_string)
112
- @socket.flush
113
- while (line = @socket.readline.chomp) != '' do @parser << line end
122
+ parser = ResponseParser.new
123
+ reconnect_attempts = 0
124
+ reconnect_jitter_rand = Random.new
114
125
 
115
- unless @parser.status_code == 200
116
- @on[:error].call({status_code: @parser.status_code, body: @parser.chunk})
117
- return
126
+ loop do
127
+ begin
128
+ if @proxy
129
+ sock = ::TCPSocket.new(@proxy.host, @proxy.port)
130
+ @socket = Celluloid::IO::TCPSocket.new(sock)
131
+
132
+ @socket.write(connect_string)
133
+ @socket.flush
134
+ while (line = readline_with_timeout(@socket).chomp) != '' do parser << line end
135
+
136
+ unless parser.status_code == 200
137
+ @on[:error].call({status_code: parser.status_code, body: parser.chunk})
138
+ return
139
+ end
140
+ else
141
+ sock = ::TCPSocket.new(@url.host, @url.port)
142
+ @socket = Celluloid::IO::TCPSocket.new(sock)
143
+ end
144
+
145
+ if ssl?
146
+ @socket = Celluloid::IO::SSLSocket.new(@socket)
147
+ @socket.connect
148
+ end
149
+
150
+ @socket.write(request_string)
151
+ @socket.flush()
152
+
153
+ until parser.headers?
154
+ parser << readline_with_timeout(@socket)
155
+ end
156
+
157
+ if parser.status_code != 200
158
+ until @socket.eof?
159
+ parser << readline_with_timeout(@socket)
160
+ end
161
+ # If the server returns a non-200, we don't want to close-- we just want to
162
+ # report an error
163
+ # close
164
+ @on[:error].call({status_code: parser.status_code, body: parser.chunk})
165
+ elsif parser.headers['Content-Type'] && parser.headers['Content-Type'].include?("text/event-stream")
166
+ @chunked = !parser.headers["Transfer-Encoding"].nil? && parser.headers["Transfer-Encoding"].include?("chunked")
167
+ @ready_state = OPEN
168
+ @on[:open].call
169
+ return # Success, don't retry
170
+ else
171
+ close
172
+ info "Invalid Content-Type #{parser.headers['Content-Type']}"
173
+ @on[:error].call({status_code: parser.status_code, body: "Invalid Content-Type #{parser.headers['Content-Type']}. Expected text/event-stream"})
174
+ raise UnexpectedContentType
175
+ end
176
+
177
+ rescue UnexpectedContentType
178
+ raise # Let these flow to the top
179
+
180
+ rescue StandardError => e
181
+ warn "Waiting to try again after exception while connecting: #{e}"
182
+ # Just try again after a delay for any other exceptions
118
183
  end
119
- else
120
- sock = ::TCPSocket.new(@url.host, @url.port)
121
- @socket = Celluloid::IO::TCPSocket.new(sock)
122
- end
123
184
 
124
- if ssl?
125
- @socket = Celluloid::IO::SSLSocket.new(@socket)
126
- @socket.connect
185
+ base_sleep_time = ([@reconnect_timeout * (2 ** reconnect_attempts), MAX_RECONNECT_TIME].min).to_f
186
+ sleep_time = (base_sleep_time / 2) + reconnect_jitter_rand.rand(base_sleep_time / 2)
187
+ sleep sleep_time
188
+ reconnect_attempts += 1
127
189
  end
128
-
129
- @socket.write(request_string)
130
- @socket.flush()
131
-
132
- until @parser.headers?
133
- @parser << @socket.readline
134
- end
135
-
136
- if @parser.status_code != 200
137
- until @socket.eof?
138
- @parser << @socket.readline
139
- end
140
- # If the server returns a non-200, we don't want to close-- we just want to
141
- # report an error
142
- # close
143
- @on[:error].call({status_code: @parser.status_code, body: @parser.chunk})
144
- return
145
- end
146
-
147
- handle_headers(@parser.headers)
148
190
  end
149
191
 
150
192
  def default_request_headers
@@ -155,100 +197,59 @@ module Celluloid
155
197
  }
156
198
  end
157
199
 
158
- def clear_buffers!
159
- @data_buffer = ""
160
- @event_type_buffer = ""
161
- end
162
-
163
- def dispatch_event(event)
164
- unless closed?
165
- @on[event.type] && @on[event.type].call(event)
166
- end
167
- end
168
-
169
200
  def chunked?
170
201
  @chunked
171
202
  end
172
203
 
173
- def process_chunked_stream
174
- until closed? || @socket.eof?
175
- handle_chunked_stream
176
- end
177
- end
178
-
179
- def process_stream
180
- until closed? || @socket.eof?
181
- line = @socket.readline
182
- line.strip.empty? ? process_event : parse_line(line)
183
- end
184
- end
185
-
186
- def handle_chunked_stream
187
- chunk_header = @socket.readline
188
- bytes_to_read = chunk_header.to_i(16)
189
- bytes_read = 0
190
- while bytes_read < bytes_to_read do
191
- line = @socket.readline
192
- bytes_read += line.size
193
-
194
- line.strip.empty? ? process_event : parse_line(line)
195
- end
196
-
197
- if !line.nil? && line.strip.empty?
198
- process_event
204
+ def read_chunked_lines(socket)
205
+ Enumerator.new do |lines|
206
+ chunk_header = readline_with_timeout(socket)
207
+ bytes_to_read = chunk_header.to_i(16)
208
+ bytes_read = 0
209
+ while bytes_read < bytes_to_read do
210
+ line = readline_with_timeout(@socket)
211
+ bytes_read += line.size
212
+ lines << line
213
+ end
199
214
  end
200
215
  end
201
216
 
202
- def parse_line(line)
203
- case line
204
- when /^:.*$/
205
- when /^(\w+): ?(.*)$/
206
- process_field($1, $2)
207
- else
208
- if chunked? && !@data_buffer.empty?
209
- @data_buffer.rstrip!
210
- process_field("data", line.rstrip)
217
+ def read_lines
218
+ Enumerator.new do |lines|
219
+ loop do
220
+ break if closed?
221
+ if chunked?
222
+ for line in read_chunked_lines(@socket) do
223
+ break if closed?
224
+ lines << line
225
+ end
226
+ else
227
+ lines << readline_with_timeout(@socket)
228
+ end
211
229
  end
212
230
  end
213
231
  end
214
232
 
215
- def process_event
216
- @last_event_id = @last_event_id_buffer
217
-
218
- return if @data_buffer.empty?
219
-
220
- @data_buffer.chomp!("\n") if @data_buffer.end_with?("\n")
221
- event = MessageEvent.new(:message, @data_buffer, @last_event_id)
222
- event.type = @event_type_buffer.to_sym unless @event_type_buffer.empty?
223
-
224
- dispatch_event(event)
225
- ensure
226
- clear_buffers!
227
- end
228
-
229
- def process_field(field_name, field_value)
230
- case field_name
231
- when "event"
232
- @event_type_buffer = field_value
233
- when "data"
234
- @data_buffer << field_value.concat("\n")
235
- when "id"
236
- @last_event_id_buffer = field_value
237
- when "retry"
238
- if /^(?<num>\d+)$/ =~ field_value
239
- @reconnect_timeout = num.to_i
240
- end
233
+ def process_stream
234
+ parser = EventParser.new(read_lines, @chunked,->(timeout) { @read_timeout = timeout })
235
+ parser.each do |event|
236
+ @on[event.type] && @on[event.type].call(event)
237
+ @last_event_id = event.id
241
238
  end
242
239
  end
243
240
 
244
- def handle_headers(headers)
245
- if headers['Content-Type'].include?("text/event-stream")
246
- @chunked = !headers["Transfer-Encoding"].nil? && headers["Transfer-Encoding"].include?("chunked")
247
- @ready_state = OPEN
248
- @on[:open].call
241
+ def readline_with_timeout(socket)
242
+ if @read_timeout > 0
243
+ begin
244
+ timeout(@read_timeout) do
245
+ socket.readline
246
+ end
247
+ rescue Celluloid::TaskTimeout
248
+ @on[:error].call({body: "Read timeout, will attempt reconnection"})
249
+ raise ReadTimeout
250
+ end
249
251
  else
250
- close
251
- @on[:error].call({status_code: @parser.status_code, body: "Invalid Content-Type #{headers['Content-Type']}. Expected text/event-stream"})
252
+ return socket.readline
252
253
  end
253
254
  end
254
255
 
@@ -267,7 +268,5 @@ module Celluloid
267
268
  end
268
269
  req << "\r\n"
269
270
  end
270
-
271
271
  end
272
-
273
272
  end
@@ -0,0 +1,76 @@
1
+ module Celluloid
2
+ class EventSource
3
+ class EventParser
4
+ include Enumerable
5
+
6
+ def initialize(lines, chunked, on_retry)
7
+ @lines = lines
8
+ @chunked = chunked
9
+ clear_buffers!
10
+ @on_retry = on_retry
11
+ end
12
+
13
+ def each
14
+ @lines.each do |line|
15
+ if line.strip.empty?
16
+ begin
17
+ event = create_event
18
+ yield event unless event.nil?
19
+ ensure
20
+ clear_buffers!
21
+ end
22
+ else
23
+ parse_line(line)
24
+ end
25
+ end
26
+ end
27
+
28
+ private
29
+
30
+ MessageEvent = Struct.new(:type, :data, :id)
31
+
32
+ def clear_buffers!
33
+ @id_buffer = ''
34
+ @data_buffer = ''
35
+ @type_buffer = ''
36
+ end
37
+
38
+ def parse_line(line)
39
+ case line
40
+ when /^:.*$/
41
+ when /^(\w+): ?(.*)$/
42
+ process_field($1, $2)
43
+ else
44
+ if @chunked && !@data_buffer.empty?
45
+ @data_buffer.rstrip!
46
+ process_field("data", line.rstrip)
47
+ end
48
+ end
49
+ end
50
+
51
+ def create_event
52
+ return nil if @data_buffer.empty?
53
+
54
+ @data_buffer.chomp!("\n") if @data_buffer.end_with?("\n")
55
+ event = MessageEvent.new(:message, @data_buffer, @id_buffer)
56
+ event.type = @type_buffer.to_sym unless @type_buffer.empty?
57
+ event
58
+ end
59
+
60
+ def process_field(field_name, field_value)
61
+ case field_name
62
+ when "event"
63
+ @type_buffer = field_value
64
+ when "data"
65
+ @data_buffer << field_value.concat("\n")
66
+ when "id"
67
+ @id_buffer = field_value
68
+ when "retry"
69
+ if /^(?<num>\d+)$/ =~ field_value
70
+ @on_retry.call(num.to_i)
71
+ end
72
+ end
73
+ end
74
+ end
75
+ end
76
+ end
@@ -1,5 +1,5 @@
1
1
  module Celluloid
2
2
  class EventSource
3
- VERSION = "0.10.0"
3
+ VERSION = "0.11.0"
4
4
  end
5
5
  end
@@ -0,0 +1,97 @@
1
+ require 'spec_helper'
2
+
3
+ RSpec.describe Celluloid::EventSource::EventParser do
4
+ it 'converts lines to events' do
5
+ lines = <<LINES
6
+ id: 123
7
+ event: my-event
8
+ data: my-data
9
+
10
+ LINES
11
+ events = Celluloid::EventSource::EventParser.new(lines.lines, false, ->(r) {}).to_a
12
+ expect(events.length).to eq 1
13
+ expect(events[0].id).to eq '123'
14
+ expect(events[0].type).to eq 'my-event'.to_sym
15
+ expect(events[0].data).to eq 'my-data'
16
+ end
17
+
18
+ it 'ignores comments' do
19
+ lines = <<LINES
20
+ : comment
21
+ id: 123
22
+ event: my-event
23
+ data: my-data
24
+
25
+
26
+ LINES
27
+ events = Celluloid::EventSource::EventParser.new(lines.lines, false, ->(r) {}).to_a
28
+ expect(events.length).to eq 1
29
+ expect(events[0].id).to eq '123'
30
+ end
31
+
32
+ it 'resets values after each event' do
33
+ lines = <<LINES
34
+ id: 123
35
+ event: my-event
36
+ data: my-data
37
+
38
+ event: my-event2
39
+ data: my-data2
40
+
41
+ LINES
42
+ events = Celluloid::EventSource::EventParser.new(lines.lines, false, ->(r) {}).to_a
43
+ expect(events.length).to eq 2
44
+ expect(events[0].id).to eq '123'
45
+ expect(events[0].type).to eq 'my-event'.to_sym
46
+ expect(events[0].data).to eq 'my-data'
47
+ expect(events[1].id).to eq ''
48
+ expect(events[1].type).to eq 'my-event2'.to_sym
49
+ expect(events[1].data).to eq 'my-data2'
50
+ end
51
+
52
+ it 'sets the default event type to message' do
53
+ lines = <<LINES
54
+ data: my-data
55
+
56
+ LINES
57
+ events = Celluloid::EventSource::EventParser.new(lines.lines, false, ->(r) {}).to_a
58
+ expect(events.length).to eq 1
59
+ expect(events[0].id).to eq ''
60
+ expect(events[0].type).to eq :message
61
+ expect(events[0].data).to eq 'my-data'
62
+ end
63
+
64
+ it 'does not generate events unless data is provided' do
65
+ lines = <<LINES
66
+ event: my-event
67
+
68
+ LINES
69
+ events = Celluloid::EventSource::EventParser.new(lines.lines, false, ->(r) {}).to_a
70
+ expect(events.length).to eq 0
71
+ end
72
+
73
+ it 'extends data with unprefixed lines as data in chunked mode' do
74
+ lines = <<LINES
75
+ data:
76
+ my-data
77
+
78
+ LINES
79
+ events = Celluloid::EventSource::EventParser.new(lines.lines, true, ->(r) {}).to_a
80
+ expect(events.length).to eq 1
81
+ expect(events[0].id).to eq ''
82
+ expect(events[0].type).to eq :message
83
+ expect(events[0].data).to eq 'my-data'
84
+ end
85
+
86
+ it 'reports retry updates to the provided function' do
87
+ lines = <<LINES
88
+ retry: 123
89
+ LINES
90
+ received_retry_args = []
91
+ set_retry = lambda { |r| received_retry_args << r }
92
+ events = Celluloid::EventSource::EventParser.new(lines.lines, true, set_retry).to_a
93
+ expect(received_retry_args).to eq [123]
94
+ expect(events.length).to eq 0
95
+ end
96
+
97
+ end
@@ -105,7 +105,7 @@ RSpec.describe Celluloid::EventSource do
105
105
 
106
106
  Celluloid::EventSource.new(dummy.endpoint) do |conn|
107
107
  conn.on_message do |message|
108
- if '3' == message.last_event_id
108
+ if '3' == message.id
109
109
  future.signal(value_class.new({ msg: message, state: conn.ready_state }))
110
110
  conn.close
111
111
  end
@@ -113,9 +113,9 @@ RSpec.describe Celluloid::EventSource do
113
113
  end
114
114
 
115
115
  payload = future.value
116
- expect(payload[:msg]).to be_a(Celluloid::EventSource::MessageEvent)
116
+ expect(payload[:msg]).to be_a(Celluloid::EventSource::EventParser::MessageEvent)
117
117
  expect(payload[:msg].type).to eq(:message)
118
- expect(payload[:msg].last_event_id).to eq('3')
118
+ expect(payload[:msg].id).to eq('3')
119
119
  expect(payload[:state]).to eq(Celluloid::EventSource::OPEN)
120
120
  end
121
121
 
@@ -189,9 +189,9 @@ RSpec.describe Celluloid::EventSource do
189
189
  end
190
190
 
191
191
  payload = future.value
192
- expect(payload[:msg]).to be_a(Celluloid::EventSource::MessageEvent)
192
+ expect(payload[:msg]).to be_a(Celluloid::EventSource::EventParser::MessageEvent)
193
193
  expect(payload[:msg].type).to eq(custom)
194
- expect(payload[:msg].last_event_id).to eq('1')
194
+ expect(payload[:msg].id).to eq('1')
195
195
  expect(payload[:state]).to eq(Celluloid::EventSource::OPEN)
196
196
  end
197
197
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ld-celluloid-eventsource
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.10.0
4
+ version: 0.11.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - LaunchDarkly
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2017-05-05 00:00:00.000000000 Z
11
+ date: 2017-11-15 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: celluloid-io
@@ -42,14 +42,14 @@ dependencies:
42
42
  name: nio4r
43
43
  requirement: !ruby/object:Gem::Requirement
44
44
  requirements:
45
- - - "~>"
45
+ - - ">="
46
46
  - !ruby/object:Gem::Version
47
47
  version: '1.1'
48
48
  type: :runtime
49
49
  prerelease: false
50
50
  version_requirements: !ruby/object:Gem::Requirement
51
51
  requirements:
52
- - - "~>"
52
+ - - ">="
53
53
  - !ruby/object:Gem::Version
54
54
  version: '1.1'
55
55
  - !ruby/object:Gem::Dependency
@@ -154,9 +154,11 @@ files:
154
154
  - Rakefile
155
155
  - ld-celluloid-eventsource.gemspec
156
156
  - lib/celluloid/eventsource.rb
157
+ - lib/celluloid/eventsource/event_parser.rb
157
158
  - lib/celluloid/eventsource/response_parser.rb
158
159
  - lib/celluloid/eventsource/version.rb
159
160
  - log/.gitignore
161
+ - spec/celluloid/eventsource/event_parser_spec.rb
160
162
  - spec/celluloid/eventsource/response_parser_spec.rb
161
163
  - spec/celluloid/eventsource_spec.rb
162
164
  - spec/spec_helper.rb
@@ -182,11 +184,12 @@ required_rubygems_version: !ruby/object:Gem::Requirement
182
184
  version: '0'
183
185
  requirements: []
184
186
  rubyforge_project:
185
- rubygems_version: 2.6.11
187
+ rubygems_version: 2.6.14
186
188
  signing_key:
187
189
  specification_version: 4
188
190
  summary: ld-celluloid-eventsource is a gem to consume SSE streaming API.
189
191
  test_files:
192
+ - spec/celluloid/eventsource/event_parser_spec.rb
190
193
  - spec/celluloid/eventsource/response_parser_spec.rb
191
194
  - spec/celluloid/eventsource_spec.rb
192
195
  - spec/spec_helper.rb