ld-celluloid-eventsource 0.10.0 → 0.11.0

Sign up to get free protection for your applications and to get access to all the features.
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