powertrack 1.0.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.
@@ -0,0 +1,140 @@
1
+ require 'multi_json'
2
+
3
+ module PowerTrack
4
+ # A PowerTrack rule with its components and restrictions.
5
+ class Rule
6
+
7
+ # The maximum length of a rule tag.
8
+ MAX_TAG_LENGTH = 255
9
+
10
+ # The maximum lengh of the value of a standard rule
11
+ MAX_STD_RULE_VALUE_LENGTH = 1024
12
+
13
+ # The maximum lengh of the value of a long rule
14
+ MAX_LONG_RULE_VALUE_LENGTH = 2048
15
+
16
+ # The maximum number of positive terms in a single rule value
17
+ MAX_POSITIVE_TERMS = 30
18
+
19
+ # The maximum number of negative terms in a single rule value
20
+ MAX_NEGATIVE_TERMS = 50
21
+
22
+ attr_reader :value, :tag, :error
23
+
24
+ # Builds a new rule based on a value and an optional tag.
25
+ # By default, the constructor assesses if it's a long rule or not
26
+ # based on the length of the value. But the 'long' feature can be
27
+ # explicitly specified with the third parameter.
28
+ def initialize(value, tag=nil, long=nil)
29
+ @value = value || ''
30
+ @tag = tag
31
+ # check if long is a boolean
32
+ @long = long == !!long ? long : @value.size > MAX_STD_RULE_VALUE_LENGTH
33
+ @error = nil
34
+ end
35
+
36
+ # Returns true if the rule is long.
37
+ def long?
38
+ @long
39
+ end
40
+
41
+ # Returns true if the rule is valid, false otherwise. The validation error
42
+ # can be through the error method.
43
+ def valid?
44
+ # reset error
45
+ @error = nil
46
+
47
+ [ :too_long_value?,
48
+ :too_many_positive_terms?,
49
+ :too_many_negative_terms?,
50
+ :contains_empty_source?,
51
+ :contains_negated_or?,
52
+ :too_long_tag? ].each do |validator|
53
+
54
+ # stop when 1 validator fails
55
+ if self.send(validator)
56
+ @error = validator.to_s.gsub(/_/, ' ').gsub(/\?/, '').capitalize
57
+ return false
58
+ end
59
+ end
60
+
61
+ true
62
+ end
63
+
64
+ # Dumps the rule in a valid JSON format.
65
+ def to_json
66
+ MultiJson.encode(to_hash)
67
+ end
68
+
69
+ # Converts the rule in a Hash.
70
+ def to_hash
71
+ res = {:value => @value}
72
+ res[:tag] = @tag unless @tag.nil?
73
+ res
74
+ end
75
+
76
+ # Converts the rule in a string, the JSON representation of the rule actually.
77
+ def to_s
78
+ to_json
79
+ end
80
+
81
+ # Returns true when the rule is equal to the other rule provided.
82
+ def ==(other)
83
+ other.class == self.class &&
84
+ other.value == @value &&
85
+ other.tag == @tag &&
86
+ other.long? == self.long?
87
+ end
88
+
89
+ alias eql? ==
90
+
91
+ # Returns a hash for the rule based on its components. Useful for using
92
+ # rules as Hash keys.
93
+ def hash
94
+ # let's assume a nil value for @value or @tag is not different from the empty value
95
+ "v:#{@value},t:#{@tag},l:#{@long}".hash
96
+ end
97
+
98
+ # Returns the maximum length of the rule value according to the type of the
99
+ # rule (long or standard).
100
+ def max_value_length
101
+ long? ? MAX_LONG_RULE_VALUE_LENGTH : MAX_STD_RULE_VALUE_LENGTH
102
+ end
103
+
104
+ protected
105
+
106
+ # Is the rule value too long ?
107
+ def too_long_value?
108
+ @value.size > max_value_length
109
+ end
110
+
111
+ # Does the rule value contain a forbidden negated OR ?
112
+ def contains_negated_or?
113
+ !@value[/\-\w+ OR/].nil? || !@value[/OR \-\w+/].nil?
114
+ end
115
+
116
+ # Does the rule value contain too many positive terms ?
117
+ def too_many_positive_terms?
118
+ return false if long?
119
+ # negative look-behind; see http://www.rexegg.com/regex-disambiguation.html
120
+ # exclude the OR operator from the terms being counted
121
+ @value.scan(/(?<!-)(\b[\w:]+|\"[\-\s\w:]+\"\b)/).select { |match| match.first != 'OR' }.size > MAX_POSITIVE_TERMS
122
+ end
123
+
124
+ # Does the rule value contain too many negative terms ?
125
+ def too_many_negative_terms?
126
+ return false if long?
127
+ @value.scan(/(^| )\-(\w|\([^(]*\)|\"[^"]*\")/).size > MAX_NEGATIVE_TERMS
128
+ end
129
+
130
+ # Does the rule value contain an empty source ?
131
+ def contains_empty_source?
132
+ !@value[/source\:\s/].nil?
133
+ end
134
+
135
+ # Is the rule tag too long ?
136
+ def too_long_tag?
137
+ @tag && @tag.size > MAX_TAG_LENGTH
138
+ end
139
+ end
140
+ end
@@ -0,0 +1,9 @@
1
+ require 'powertrack/rules/rule'
2
+
3
+ # Extend core String class with a rule transformer
4
+ class String
5
+ # Returns a PowerTrace::Rule instance based on the value of the string.
6
+ def to_pwtk_rule(tag=nil, long=nil)
7
+ PowerTrack::Rule.new(self, tag, long)
8
+ end
9
+ end
@@ -0,0 +1,64 @@
1
+ module PowerTrack
2
+ module API
3
+ # Adds many rules to your PowerTrack stream’s ruleset.
4
+ #
5
+ # <tt>POST /rules</tt>
6
+ #
7
+ # See http://support.gnip.com/apis/powertrack/api_reference.html#AddRules
8
+ def add_rules(*rules)
9
+ raise NotImplementedError
10
+ end
11
+
12
+ # Adds one rule to your PowerTrack stream’s ruleset.
13
+ #
14
+ # <tt>POST /rules</tt>
15
+ #
16
+ # See http://support.gnip.com/apis/powertrack/api_reference.html#AddRules
17
+ def add_rule(rule)
18
+ add_rules(rule)
19
+ end
20
+
21
+ # Removes the specified rules from the stream.
22
+ #
23
+ # <tt>DELETE /rules</tt>
24
+ #
25
+ # See http://support.gnip.com/apis/powertrack/api_reference.html#DeleteRules
26
+ def delete_rules(*rules)
27
+ raise NotImplementedError
28
+ end
29
+
30
+ # Removes the specified rule from the stream.
31
+ #
32
+ # <tt>DELETE /rules</tt>
33
+ #
34
+ # See http://support.gnip.com/apis/powertrack/api_reference.html#DeleteRules
35
+ def delete_rule(rule)
36
+ delete_rules(rule)
37
+ end
38
+
39
+ # Retrieves all existing rules for a stream.
40
+ #
41
+ # <tt>GET /rules</tt>
42
+ #
43
+ # See http://support.gnip.com/apis/powertrack/api_reference.html#ListRules
44
+ #
45
+ # Options:
46
+ # o compressed: [true|false] To demand gzip-compressed response from GNIP
47
+ # true by default
48
+ # o objectify: [true|false] To demand PowerTrack::Rule object as results
49
+ # instead of raw JSON. True by default.
50
+ def list_rules(options=nil)
51
+ raise NotImplementedError
52
+ end
53
+
54
+ # Establishes a persistent connection to the PowerTrack data stream,
55
+ # through which the social data will be delivered.
56
+ #
57
+ # <tt>GET /track/:stream</tt>
58
+ #
59
+ # See http://support.gnip.com/apis/powertrack/api_reference.html#Stream
60
+ def track(options=nil)
61
+ raise NotImplementedError
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,36 @@
1
+ module PowerTrack
2
+ # A buffer of data received from PowerTrack. Useful for managing the sequential
3
+ # chunk of bytes sent of the stream by GNIP and slice them into well-formatted
4
+ # messages.
5
+ class DataBuffer
6
+
7
+ # The pattern used by GNIP PowerTrack to delimitate a single message.
8
+ MESSAGE_PATTERN = /^([^\r]*)\r\n/m
9
+
10
+ # Builds a new data buffer.
11
+ def initialize
12
+ @buffer = ''
13
+ end
14
+
15
+ # Add a chunk of bytes to the buffer and pass the new message(s) extracted
16
+ # to the block provided.
17
+ def process(chunk, &block)
18
+ @buffer.concat(chunk)
19
+ @buffer.gsub!(MESSAGE_PATTERN) do |match|
20
+ yield($1.to_s) if block_given?
21
+ # erase the message
22
+ ''
23
+ end
24
+ end
25
+
26
+ # The current size of the buffer.
27
+ def size
28
+ @buffer.size
29
+ end
30
+
31
+ # Resets the buffer, therefore losing any bytes received from PowerTrack.
32
+ def reset!
33
+ @buffer = ''
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,70 @@
1
+ require 'exponential_backoff'
2
+
3
+ module PowerTrack
4
+ # A utility class that manges an exponential backoff retry pattern.
5
+ # Additionally, this king of retrier can be reset or stopped by the code being
6
+ # retried.
7
+ class Retrier
8
+ attr_reader :retries, :max_retries
9
+
10
+ # the default minimum number of seconds b/w 2 attempts
11
+ DEFAULT_MIN_INTERVAL = 1.0
12
+ # the default maximum number of seconds to wait b/w 2 attempts
13
+ DEFAULT_MAX_ELAPSED_TIME = 30.0
14
+ # the default interval multiplier
15
+ DEFAULT_INTERVAL_MULTIPLIER = 1.5
16
+ # the default randomize factor
17
+ DEFAULT_RANDOMIZE_FACTOR = 0.25
18
+
19
+ # default options used by a retrier unless others specified at initialization
20
+ DEFAULT_OPTIONS = {
21
+ min_interval: DEFAULT_MIN_INTERVAL,
22
+ max_elapsed_time: DEFAULT_MAX_ELAPSED_TIME,
23
+ multiplier: DEFAULT_INTERVAL_MULTIPLIER,
24
+ randomize_factor: DEFAULT_RANDOMIZE_FACTOR
25
+ }
26
+
27
+ # Builds a retrier that will retry a maximum retries number of times.
28
+ def initialize(max_retries, options=nil)
29
+ options = DEFAULT_OPTIONS.merge(options || {})
30
+
31
+ @max_retries = max_retries
32
+ @retries = 0
33
+ @continue = true
34
+ @backoff = ExponentialBackoff.new(options[:min_interval], options[:max_elapsed_time])
35
+ @backoff.multiplier = options[:multiplier]
36
+ @backoff.randomize_factor = options[:randomize_factor]
37
+ end
38
+
39
+ # Resets the retrier.
40
+ def reset!
41
+ @retries = 0
42
+ @backoff.clear
43
+ end
44
+
45
+ # Returns true if the retrier is currently retrying.
46
+ def retrying?
47
+ @retries != 0
48
+ end
49
+
50
+ # Stops retrying even after a reset. To be used from the code being retried.
51
+ def stop
52
+ @continue = false
53
+ end
54
+
55
+ # Retries the block of code provided according to the configuration of the
56
+ # retrier.
57
+ def retry(&block)
58
+ # TODO: manage exceptions
59
+ while @continue && @retries < @max_retries
60
+ res = yield
61
+ if @continue
62
+ @retries += 1
63
+ sleep(@backoff.next_interval)
64
+ end
65
+ end
66
+
67
+ res
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,429 @@
1
+ require 'eventmachine'
2
+ require 'em-http-request'
3
+ require 'multi_json'
4
+ require 'void_logger'
5
+
6
+ require 'powertrack/errors'
7
+ require 'powertrack/streaming/api'
8
+ require 'powertrack/streaming/data_buffer'
9
+ require 'powertrack/streaming/retrier'
10
+
11
+ module PowerTrack
12
+ # A PowerTrack stream to be used for both updating the rules and collecting
13
+ # new messages.
14
+ class Stream
15
+ # Includes the PowerTrack Stream API
16
+ include PowerTrack::API
17
+ # Includes a logger, void by default
18
+ include VoidLogger::LoggerMixin
19
+
20
+ # The format of the URLs to connect to the various stream services
21
+ FEATURE_URL_FORMAT = "https://%s:%s/accounts/%s/publishers/%s/streams/track/%s%s.json".freeze
22
+
23
+ # The default timeout on a connection to PowerTrack. Can be overriden per call.
24
+ DEFAULT_CONNECTION_TIMEOUT = 30
25
+
26
+ # The default timeout for inactivity on a connection to PowerTrack. Can be
27
+ # overriden per call.
28
+ DEFAULT_INACTIVITY_TIMEOUT = 50
29
+
30
+ # The default options for using the stream.
31
+ DEFAULT_STREAM_OPTIONS = {
32
+ connect_timeout: DEFAULT_CONNECTION_TIMEOUT,
33
+ inactivity_timeout: DEFAULT_INACTIVITY_TIMEOUT,
34
+ # use a client id if you want to leverage the Backfill feature
35
+ client_id: nil
36
+ }
37
+
38
+ DEFAULT_OK_RESPONSE_STATUS = 200
39
+
40
+ # the patterns used to identify the various types of message received from GNIP
41
+ # everything else is an activity
42
+ HEARTBEAT_MESSAGE_PATTERN = /\A\s*\z/
43
+ SYSTEM_MESSAGE_PATTERN = /\A\s*\{\s*"(info|warn|error)":/mi
44
+
45
+ attr_reader :username, :account_name, :data_source, :label
46
+
47
+ def initialize(username, password, account_name, data_source, label, options=nil)
48
+ @username = username
49
+ @password = password
50
+ @account_name = account_name
51
+ @data_source = data_source
52
+ @label = label
53
+ @options = DEFAULT_STREAM_OPTIONS.merge(options || {})
54
+ @client_id = @options[:client_id]
55
+ end
56
+
57
+ # Adds many rules to your PowerTrack stream’s ruleset.
58
+ #
59
+ # <tt>POST /rules</tt>
60
+ #
61
+ # See http://support.gnip.com/apis/powertrack/api_reference.html#AddRules
62
+ def add_rules(*rules)
63
+ # flatten the rules in case it was provided as an array
64
+ make_rules_request(:post, body: MultiJson.encode('rules' => rules.flatten), ok: 201)
65
+ end
66
+
67
+ # Removes the specified rules from the stream.
68
+ #
69
+ # <tt>DELETE /rules</tt>
70
+ #
71
+ # See http://support.gnip.com/apis/powertrack/api_reference.html#DeleteRules
72
+ def delete_rules(*rules)
73
+ # flatten the rules in case it was provided as an array
74
+ make_rules_request(:delete, body: MultiJson.encode('rules' => rules.flatten))
75
+ end
76
+
77
+ DEFAULT_LIST_RULES_OPTIONS = {
78
+ compressed: true,
79
+ objectify: true
80
+ }.freeze
81
+
82
+ # Retrieves all existing rules for a stream.
83
+ #
84
+ # Returns an array of PowerTrack::Rule objects when the response permits so.
85
+ #
86
+ # <tt>GET /rules</tt>
87
+ #
88
+ # See http://support.gnip.com/apis/powertrack/api_reference.html#ListRules
89
+ def list_rules(options=nil)
90
+ options = DEFAULT_LIST_RULES_OPTIONS.merge(options || {})
91
+ res = make_rules_request(:get, headers: gzip_compressed_header(options[:compressed]))
92
+
93
+ # return Rule objects when required and feasible/appropriate
94
+ if options[:objectify] &&
95
+ res.is_a?(Hash) &&
96
+ (rules = res['rules']).is_a?(Array) &&
97
+ rules.all? { |rule| rule.is_a?(Hash) && rule.key?('value') }
98
+ rules.map { |rule| PowerTrack::Rule.new(rule['value'], rule['tag']) }
99
+ else
100
+ res
101
+ end
102
+ end
103
+
104
+ DEFAULT_TRACK_OPTIONS = {
105
+ # receive GZip-compressed payloads ?
106
+ compressed: true,
107
+ # max number of retries after a disconnection
108
+ max_retries: 3,
109
+ # advanced options to configure exponential backoff used for retries
110
+ backoff: nil,
111
+ # max number of seconds to wait for last message handlers to complete
112
+ stop_timeout: 10,
113
+ # pass message in raw form (JSON formatted string) instead of JSON-decoded
114
+ # Ruby objects to message handlers
115
+ raw: false,
116
+ # called for each message received, except heartbeats
117
+ on_message: nil,
118
+ # called for each activity received
119
+ on_activity: nil,
120
+ # called for each system message received
121
+ on_system: nil,
122
+ # called for each heartbeat received
123
+ on_heartbeat: nil,
124
+ # called periodically to detect if the tracked has to be closed
125
+ close_now: nil
126
+ }.freeze
127
+
128
+ # Establishes a persistent connection to the PowerTrack data stream,
129
+ # through which the social data will be delivered.
130
+ #
131
+ # <tt>GET /track/:stream</tt>
132
+ #
133
+ # See http://support.gnip.com/apis/powertrack/api_reference.html#Stream
134
+ def track(options=nil)
135
+ options = DEFAULT_TRACK_OPTIONS.merge(options || {})
136
+ retrier = PowerTrack::Retrier.new(options[:max_retries])
137
+ handle_api_response(*retrier.retry { track_once(options, retrier) })
138
+ end
139
+
140
+ private
141
+
142
+ # Returns the fully-qualified domain name of a GNIP PowerTrack server
143
+ # based on a hostname.
144
+ def gnip_server_name(hostname)
145
+ "%s.gnip.com" % [ hostname ]
146
+ end
147
+
148
+ # Returns the port used by GNIP PowerTrack servers.
149
+ def gnip_server_port
150
+ '443'
151
+ end
152
+
153
+ # Returns the URL of the stream for a given feature.
154
+ def feature_url(hostname, feature=nil)
155
+ feature = feature ? "/#{feature}" : ''
156
+ _url = FEATURE_URL_FORMAT %
157
+ [ gnip_server_name(hostname),
158
+ gnip_server_port,
159
+ @account_name,
160
+ @data_source,
161
+ @label,
162
+ feature ]
163
+
164
+ _url += "?client=#{@client_id}" if @client_id
165
+
166
+ _url
167
+ end
168
+
169
+ # Returns the HTTP header that turns on GZip-based compression if required.
170
+ # Each call returns a new hash which can be safely modified by the caller.
171
+ def gzip_compressed_header(compressed)
172
+ compressed ? { 'accept-encoding' => 'gzip, compressed' } : {}
173
+ end
174
+
175
+ # Returns the authorization header to join to the HTTP request.
176
+ def auth_header
177
+ { 'authorization' => [ @username, @password ] }
178
+ end
179
+
180
+ # Returns the HTTP headers common to each valid PowerTrack connection.
181
+ # Each call returns a new hash which can be safely modified by the caller.
182
+ def connection_headers
183
+ { connect_timeout: @options[:connect_timeout],
184
+ inactivity_timeout: @options[:inactivity_timeout] }
185
+ end
186
+
187
+ # Opens a new connection to GNIP PowerTrack.
188
+ def connect(hostname, feature=nil)
189
+ url = feature_url(hostname, feature)
190
+ EventMachine::HttpRequest.new(url, connection_headers)
191
+ end
192
+
193
+ # Returns the HTTP headers common to each valid PowerTrack request.
194
+ # Each call returns a new hash which can be safely modified by the caller.
195
+ def common_req_headers
196
+ { 'accept' => 'application/json',
197
+ 'content-type' => 'application/json; charset=utf-8',
198
+ :redirects => 3 }.merge(auth_header)
199
+ end
200
+
201
+ # Returns the HTTP headers common to each valid /rules request.
202
+ # Each call returns a new hash which can be safely modified by the caller.
203
+ def rules_req_headers
204
+ common_req_headers
205
+ end
206
+
207
+ # Parses a JSON-formatted body received as the response of a PowerTrack API
208
+ # request.
209
+ #
210
+ # Returns nil when the body is empty, the Ruby object decoded from the
211
+ # JSON-formatted body otherwise.
212
+ #
213
+ # If the parsing fails, returns the value returned by the given block which
214
+ # is called with the textual body as a single argument. If no block id,
215
+ # return the textual body initially received.
216
+ def parse_json_body(body, &block)
217
+ body = (body || '').strip
218
+ begin
219
+ body == '' ? nil : MultiJson.load(body)
220
+ rescue
221
+ if block_given?
222
+ yield($!)
223
+ else
224
+ body
225
+ end
226
+ end
227
+ end
228
+
229
+ # Returns an appropriate return value or exception according to the response
230
+ # obtained on an API request.
231
+ def handle_api_response(status, error, body, ok=DEFAULT_OK_RESPONSE_STATUS)
232
+ case status
233
+ when nil
234
+ # connection issue
235
+ raise PowerTrack::ConnectionError.new(error)
236
+ when ok
237
+ # successful call: return the body unless there isn't any
238
+ return nil if body.nil?
239
+
240
+ parse_json_body(body) do |exception|
241
+ # invalid JSON response
242
+ raise PowerTrack::InvalidResponseError.new(ok, exception.message, body)
243
+ end
244
+ else
245
+ # specified response status
246
+ raise PowerTrack::WithStatusPowerTrackError.build(status, error, parse_json_body(body))
247
+ end
248
+ end
249
+
250
+ DEFAULT_RULES_REQUEST_OPTIONS = {
251
+ ok: DEFAULT_OK_RESPONSE_STATUS,
252
+ headers: {},
253
+ body: nil
254
+ }
255
+
256
+ # Makes a rules-related request with a specific HTTP verb and a few options.
257
+ # Returns the response if successful or an exception if the request failed.
258
+ def make_rules_request(verb, options=nil)
259
+ options = DEFAULT_RULES_REQUEST_OPTIONS.merge(options || {})
260
+ resp_status = nil
261
+ resp_error = nil
262
+ resp_body = nil
263
+
264
+ EM.run do
265
+ con = connect('api', 'rules')
266
+ http = con.setup_request(verb,
267
+ head: rules_req_headers.merge(options[:headers]),
268
+ body: options[:body])
269
+
270
+ http.errback do
271
+ resp_error = http.error
272
+ EM.stop
273
+ end
274
+
275
+ http.callback do
276
+ resp_status = http.response_header.status
277
+ resp_error = http.error
278
+ resp_body = http.response
279
+ EM.stop
280
+ end
281
+ end
282
+
283
+ handle_api_response(resp_status, resp_error, resp_body, options[:ok])
284
+ end
285
+
286
+ # Returns the type of message received on the stream, nil when the type
287
+ # cannot be identified.
288
+ def message_type(message)
289
+ case message
290
+ when HEARTBEAT_MESSAGE_PATTERN then :heartbeat
291
+ when SYSTEM_MESSAGE_PATTERN then :system
292
+ else
293
+ :activity
294
+ end
295
+ end
296
+
297
+ # Returns the HTTP headers for each valid /track request.
298
+ # Each call returns a new hash which can be safely modified by the caller.
299
+ def track_req_headers(compressed)
300
+ common_req_headers.merge('connection' => 'keep-alive')
301
+ .merge(gzip_compressed_header(compressed))
302
+ end
303
+
304
+ # Connects to the /track endpoint and manages reconnections when being
305
+ # disconnected.
306
+ def track_once(options, retrier)
307
+ logger.info "Starting tracker for retry ##{retrier.retries}..."
308
+ stop_timeout = options[:stop_timeout]
309
+ on_heartbeat = options[:on_heartbeat]
310
+ on_message = options[:on_message]
311
+ on_activity = options[:on_activity]
312
+ close_now = options[:close_now] || lambda { false }
313
+
314
+ buffer = PowerTrack::DataBuffer.new
315
+ closed = false
316
+ disconnected = false
317
+ resp_status = DEFAULT_OK_RESPONSE_STATUS
318
+ resp_error = nil
319
+ resp_body = nil
320
+
321
+ EM.run do
322
+ logger.info "Starting the reactor..."
323
+ con = connect('stream')
324
+ http = con.get(head: track_req_headers(options[:compressed]))
325
+
326
+ # polls to see if the connection should be closed
327
+ close_watcher = EM.add_periodic_timer(1) do
328
+ # exit if required
329
+ if close_now.call
330
+ logger.info "Time to close the tracker"
331
+ closed = true
332
+ close_watcher.cancel
333
+ con.close
334
+ end
335
+ end
336
+
337
+ # simulate periodic disconnections
338
+ if options[:fake_disconnections]
339
+ EM.add_timer(rand(options[:fake_disconnections])) do
340
+ con.close
341
+ end
342
+ end
343
+
344
+ http.stream do |chunk|
345
+ # ignore data if already disconnected, thus avoiding synchronizing the
346
+ # buffer. Nevertheless, this should never happen...
347
+ # TODO: log a warning if it happens
348
+
349
+ if disconnected
350
+ logger.warn "Message received while already disconnected"
351
+ next
352
+ end
353
+
354
+ # reset retries when some (valid) data are received
355
+ if retrier.retrying?
356
+ logger.info "Resetting retries..."
357
+ retrier.reset!
358
+ end
359
+
360
+ # process the chunk
361
+ buffer.process(chunk) do |raw|
362
+ logger.debug "New message received"
363
+ EM.defer do
364
+ # select the right message handler(s) according to the message type
365
+ m_type = message_type(raw)
366
+
367
+ if m_type == :heartbeat
368
+ on_heartbeat.call if on_heartbeat
369
+ else
370
+ # JSON decoding if required
371
+ message = options[:raw] ? raw : MultiJson.decode(raw)
372
+
373
+ on_message.call(message) if on_message
374
+
375
+ case m_type
376
+ when :system then on_system.call(message) if on_system
377
+ when :activity then on_activity.call(message) if on_activity
378
+ end
379
+ end
380
+
381
+ # TODO: manage exceptions at this level
382
+ end
383
+ end
384
+ end
385
+
386
+ # reconnection on error
387
+ reconnect_cb = lambda do |http_client|
388
+ logger.info "Disconnected after #{retrier.retries} retries"
389
+ disconnected = true
390
+
391
+ if closed
392
+ # close immediately if required
393
+ wait_til_defers_finish_and_stop(stop_timeout)
394
+ # tell the retrier the tracking is over
395
+ retrier.stop
396
+ else
397
+ # cancel the periodic close watcher
398
+ close_watcher.cancel
399
+
400
+ resp_status = http_client.response_header.status || DEFAULT_OK_RESPONSE_STATUS
401
+ resp_error = http_client.error
402
+ resp_body = http_client.response
403
+ wait_til_defers_finish_and_stop(stop_timeout)
404
+ end
405
+ end
406
+
407
+ http.callback(&reconnect_cb)
408
+ http.errback(&reconnect_cb)
409
+ end
410
+
411
+ [ resp_status, resp_error, resp_body ]
412
+ end
413
+
414
+ # Waits for all the deferrable threads to complete, then stops the reactor.
415
+ def wait_til_defers_finish_and_stop(timeout)
416
+ # wait for defers to terminate but no more than timeout...
417
+ start = Time.now
418
+ defers_waiter = EM.add_periodic_timer(0.2) do
419
+ logger.info "Waiting for defers..."
420
+ if EM.defers_finished? || (Time.now - start) > timeout
421
+ defers_waiter.cancel
422
+ end
423
+ end
424
+ ensure
425
+ logger.info "Stopping the reactor..."
426
+ EM.stop
427
+ end
428
+ end
429
+ end