ld-eventsource 1.0.0 → 2.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.
- checksums.yaml +5 -5
- data/.circleci/config.yml +13 -52
- data/.gitignore +1 -0
- data/.ldrelease/config.yml +17 -0
- data/CHANGELOG.md +13 -0
- data/Gemfile.lock +30 -10
- data/README.md +2 -2
- data/ld-eventsource.gemspec +2 -3
- data/lib/ld-eventsource/client.rb +97 -31
- data/lib/ld-eventsource/impl/backoff.rb +0 -4
- data/lib/ld-eventsource/version.rb +1 -1
- data/spec/backoff_spec.rb +52 -0
- data/spec/client_spec.rb +91 -8
- data/spec/http_stub.rb +2 -0
- metadata +15 -30
- data/Rakefile +0 -5
- data/lib/ld-eventsource/impl/streaming_http.rb +0 -222
- data/spec/streaming_http_spec.rb +0 -263
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
|
-
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
2
|
+
SHA1:
|
|
3
|
+
metadata.gz: 385c043867b40ba6ddb46cb1c9b62837018b06cb
|
|
4
|
+
data.tar.gz: a64958fc36954f72b93c59d076ebdfb06787e13e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 96cc3666962b0009ea86ec049ac1f0cac778c630c33037730007b7fa5eb338fbe779726004f0b63fa9a3bd4f6ad137879d731a3415ec1e2012de193fef609743
|
|
7
|
+
data.tar.gz: 485e8d424a5a9afb394d780fb06790af17f8bc81f03e7c9a5a2eef391c66cfe7fd006e30317b472150cadc3c73ddae712f75e6444d2e7419020e34e065db99c0
|
data/.circleci/config.yml
CHANGED
|
@@ -4,11 +4,10 @@ workflows:
|
|
|
4
4
|
version: 2
|
|
5
5
|
test:
|
|
6
6
|
jobs:
|
|
7
|
-
- test-misc-rubies
|
|
8
|
-
- test-2.2
|
|
9
|
-
- test-2.3
|
|
10
|
-
- test-2.4
|
|
11
7
|
- test-2.5
|
|
8
|
+
- test-2.6
|
|
9
|
+
- test-2.7
|
|
10
|
+
- test-3.0
|
|
12
11
|
- test-jruby-9.2
|
|
13
12
|
|
|
14
13
|
ruby-docker-template: &ruby-docker-template
|
|
@@ -18,6 +17,7 @@ ruby-docker-template: &ruby-docker-template
|
|
|
18
17
|
if [[ $CIRCLE_JOB == test-jruby* ]]; then
|
|
19
18
|
gem install jruby-openssl; # required by bundler, no effect on Ruby MRI
|
|
20
19
|
fi
|
|
20
|
+
- run: sudo apt-get update -y && sudo apt-get install -y build-essential
|
|
21
21
|
- run: ruby -v
|
|
22
22
|
- run: gem install bundler -v "~> 1.17"
|
|
23
23
|
- run: bundle install
|
|
@@ -29,62 +29,23 @@ ruby-docker-template: &ruby-docker-template
|
|
|
29
29
|
path: ./rspec
|
|
30
30
|
|
|
31
31
|
jobs:
|
|
32
|
-
test-2.
|
|
32
|
+
test-2.5:
|
|
33
33
|
<<: *ruby-docker-template
|
|
34
34
|
docker:
|
|
35
|
-
- image: circleci/ruby:2.
|
|
36
|
-
test-2.
|
|
35
|
+
- image: circleci/ruby:2.5
|
|
36
|
+
test-2.6:
|
|
37
37
|
<<: *ruby-docker-template
|
|
38
38
|
docker:
|
|
39
|
-
- image: circleci/ruby:2.
|
|
40
|
-
test-2.
|
|
39
|
+
- image: circleci/ruby:2.6
|
|
40
|
+
test-2.7:
|
|
41
41
|
<<: *ruby-docker-template
|
|
42
42
|
docker:
|
|
43
|
-
- image: circleci/ruby:2.
|
|
44
|
-
test-
|
|
43
|
+
- image: circleci/ruby:2.7
|
|
44
|
+
test-3.0:
|
|
45
45
|
<<: *ruby-docker-template
|
|
46
46
|
docker:
|
|
47
|
-
- image: circleci/ruby:
|
|
47
|
+
- image: circleci/ruby:3.0
|
|
48
48
|
test-jruby-9.2:
|
|
49
49
|
<<: *ruby-docker-template
|
|
50
50
|
docker:
|
|
51
|
-
- image: circleci/jruby:9-jdk
|
|
52
|
-
|
|
53
|
-
# The following very slow job uses an Ubuntu container to run the Ruby versions that
|
|
54
|
-
# CircleCI doesn't provide Docker images for.
|
|
55
|
-
test-misc-rubies:
|
|
56
|
-
machine:
|
|
57
|
-
image: circleci/classic:latest
|
|
58
|
-
environment:
|
|
59
|
-
- RUBIES: "jruby-9.1.17.0"
|
|
60
|
-
steps:
|
|
61
|
-
- checkout
|
|
62
|
-
- run:
|
|
63
|
-
name: install all Ruby versions
|
|
64
|
-
command: "parallel rvm install ::: $RUBIES"
|
|
65
|
-
- run:
|
|
66
|
-
name: bundle install for all versions
|
|
67
|
-
shell: /bin/bash -leo pipefail # need -l in order for "rvm use" to work
|
|
68
|
-
command: |
|
|
69
|
-
set -e;
|
|
70
|
-
for i in $RUBIES;
|
|
71
|
-
do
|
|
72
|
-
rvm use $i;
|
|
73
|
-
if [[ $i == jruby* ]]; then
|
|
74
|
-
gem install jruby-openssl; # required by bundler, no effect on Ruby MRI
|
|
75
|
-
fi
|
|
76
|
-
gem install bundler -v "~> 1.17";
|
|
77
|
-
bundle install;
|
|
78
|
-
mv Gemfile.lock "Gemfile.lock.$i"
|
|
79
|
-
done
|
|
80
|
-
- run:
|
|
81
|
-
name: run tests for all versions
|
|
82
|
-
shell: /bin/bash -leo pipefail
|
|
83
|
-
command: |
|
|
84
|
-
set -e;
|
|
85
|
-
for i in $RUBIES;
|
|
86
|
-
do
|
|
87
|
-
rvm use $i;
|
|
88
|
-
cp "Gemfile.lock.$i" Gemfile.lock;
|
|
89
|
-
bundle exec rspec spec;
|
|
90
|
-
done
|
|
51
|
+
- image: circleci/jruby:9.2-jdk
|
data/.gitignore
CHANGED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
repo:
|
|
2
|
+
public: ruby-eventsource
|
|
3
|
+
|
|
4
|
+
publications:
|
|
5
|
+
- url: https://rubygems.org/gems/ld-eventsource
|
|
6
|
+
description: RubyGems
|
|
7
|
+
- url: https://www.rubydoc.info/gems/ld-eventsource
|
|
8
|
+
description: documentation
|
|
9
|
+
|
|
10
|
+
releasableBranches:
|
|
11
|
+
- name: master
|
|
12
|
+
description: 2.x - based on the http gem
|
|
13
|
+
- name: 1.x
|
|
14
|
+
description: 1.x - based on the socketry gem
|
|
15
|
+
|
|
16
|
+
template:
|
|
17
|
+
name: ruby
|
data/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,19 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to the LaunchDarkly SSE Client for Ruby will be documented in this file. This project adheres to [Semantic Versioning](http://semver.org).
|
|
4
4
|
|
|
5
|
+
## [1.0.3] - 2020-03-17
|
|
6
|
+
### Fixed:
|
|
7
|
+
- The backoff delay logic for reconnecting after a stream failure was broken so that if a failure occurred after a stream had been active for at least `reconnect_reset_interval` (default 60 seconds), retries would use _no_ delay, potentially causing a flood of requests and a spike in CPU usage.
|
|
8
|
+
|
|
9
|
+
## [1.0.2] - 2020-03-10
|
|
10
|
+
### Removed:
|
|
11
|
+
- Removed an unused dependency on `rake`. There are no other changes in this release.
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
## [1.0.1] - 2019-07-10
|
|
15
|
+
### Fixed:
|
|
16
|
+
- Calling `close` on the client could cause a misleading warning message in the log, such as `Unexpected error from event source: #<IOError: stream closed in another thread>`.
|
|
17
|
+
|
|
5
18
|
## [1.0.0] - 2019-01-03
|
|
6
19
|
|
|
7
20
|
Initial release.
|
data/Gemfile.lock
CHANGED
|
@@ -1,19 +1,36 @@
|
|
|
1
1
|
PATH
|
|
2
2
|
remote: .
|
|
3
3
|
specs:
|
|
4
|
-
ld-eventsource (
|
|
4
|
+
ld-eventsource (2.0.0)
|
|
5
5
|
concurrent-ruby (~> 1.0)
|
|
6
|
-
|
|
7
|
-
socketry (~> 0.5.1)
|
|
6
|
+
http (~> 4.4.1)
|
|
8
7
|
|
|
9
8
|
GEM
|
|
10
9
|
remote: https://rubygems.org/
|
|
11
10
|
specs:
|
|
12
|
-
|
|
11
|
+
addressable (2.7.0)
|
|
12
|
+
public_suffix (>= 2.0.2, < 5.0)
|
|
13
|
+
concurrent-ruby (1.1.8)
|
|
13
14
|
diff-lcs (1.3)
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
15
|
+
domain_name (0.5.20190701)
|
|
16
|
+
unf (>= 0.0.5, < 1.0.0)
|
|
17
|
+
ffi (1.14.2)
|
|
18
|
+
ffi (1.14.2-java)
|
|
19
|
+
ffi-compiler (1.0.1)
|
|
20
|
+
ffi (>= 1.0.0)
|
|
21
|
+
rake
|
|
22
|
+
http (4.4.1)
|
|
23
|
+
addressable (~> 2.3)
|
|
24
|
+
http-cookie (~> 1.0)
|
|
25
|
+
http-form_data (~> 2.2)
|
|
26
|
+
http-parser (~> 1.2.0)
|
|
27
|
+
http-cookie (1.0.3)
|
|
28
|
+
domain_name (~> 0.5)
|
|
29
|
+
http-form_data (2.3.0)
|
|
30
|
+
http-parser (1.2.3)
|
|
31
|
+
ffi-compiler (>= 1.0, < 2.0)
|
|
32
|
+
public_suffix (4.0.6)
|
|
33
|
+
rake (13.0.3)
|
|
17
34
|
rspec (3.7.0)
|
|
18
35
|
rspec-core (~> 3.7.0)
|
|
19
36
|
rspec-expectations (~> 3.7.0)
|
|
@@ -29,8 +46,11 @@ GEM
|
|
|
29
46
|
rspec-support (3.7.0)
|
|
30
47
|
rspec_junit_formatter (0.3.0)
|
|
31
48
|
rspec-core (>= 2, < 4, != 2.12.0)
|
|
32
|
-
|
|
33
|
-
|
|
49
|
+
unf (0.1.4)
|
|
50
|
+
unf_ext
|
|
51
|
+
unf (0.1.4-java)
|
|
52
|
+
unf_ext (0.0.7.7)
|
|
53
|
+
webrick (1.7.0)
|
|
34
54
|
|
|
35
55
|
PLATFORMS
|
|
36
56
|
java
|
|
@@ -39,9 +59,9 @@ PLATFORMS
|
|
|
39
59
|
DEPENDENCIES
|
|
40
60
|
bundler (~> 1.7)
|
|
41
61
|
ld-eventsource!
|
|
42
|
-
rake (~> 10.0)
|
|
43
62
|
rspec (~> 3.2)
|
|
44
63
|
rspec_junit_formatter (~> 0.3.0)
|
|
64
|
+
webrick (~> 1.7)
|
|
45
65
|
|
|
46
66
|
BUNDLED WITH
|
|
47
67
|
1.17.3
|
data/README.md
CHANGED
|
@@ -3,14 +3,14 @@ LaunchDarkly SSE Client for Ruby
|
|
|
3
3
|
|
|
4
4
|
[](http://badge.fury.io/rb/ld-eventsource) [](https://circleci.com/gh/launchdarkly/ruby-eventsource/tree/master)
|
|
5
5
|
|
|
6
|
-
A client for the [Server-Sent Events](https://www.w3.org/TR/eventsource/) protocol. This implementation runs on a worker thread, and uses the [`
|
|
6
|
+
A client for the [Server-Sent Events](https://www.w3.org/TR/eventsource/) protocol. This implementation runs on a worker thread, and uses the [`http`](https://rubygems.org/gems/http) gem to manage a persistent connection. Its primary purpose is to support the [LaunchDarkly SDK for Ruby](https://github.com/launchdarkly/ruby-client), but it can be used independently.
|
|
7
7
|
|
|
8
8
|
Parts of this code are based on https://github.com/Tonkpils/celluloid-eventsource, but it does not use Celluloid.
|
|
9
9
|
|
|
10
10
|
Supported Ruby versions
|
|
11
11
|
-----------------------
|
|
12
12
|
|
|
13
|
-
This gem has a minimum Ruby version of 2.
|
|
13
|
+
This gem has a minimum Ruby version of 2.5, or 9.2 for JRuby.
|
|
14
14
|
|
|
15
15
|
Quick setup
|
|
16
16
|
-----------
|
data/ld-eventsource.gemspec
CHANGED
|
@@ -22,10 +22,9 @@ Gem::Specification.new do |spec|
|
|
|
22
22
|
|
|
23
23
|
spec.add_development_dependency "bundler", "~> 1.7"
|
|
24
24
|
spec.add_development_dependency "rspec", "~> 3.2"
|
|
25
|
-
spec.add_development_dependency "rake", "~> 10.0"
|
|
26
25
|
spec.add_development_dependency "rspec_junit_formatter", "~> 0.3.0"
|
|
26
|
+
spec.add_development_dependency "webrick", "~> 1.7"
|
|
27
27
|
|
|
28
28
|
spec.add_runtime_dependency "concurrent-ruby", "~> 1.0"
|
|
29
|
-
spec.add_runtime_dependency "
|
|
30
|
-
spec.add_runtime_dependency "socketry", "~> 0.5.1"
|
|
29
|
+
spec.add_runtime_dependency "http", "~> 4.4.1"
|
|
31
30
|
end
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
require "ld-eventsource/impl/backoff"
|
|
2
2
|
require "ld-eventsource/impl/event_parser"
|
|
3
|
-
require "ld-eventsource/impl/streaming_http"
|
|
4
3
|
require "ld-eventsource/events"
|
|
5
4
|
require "ld-eventsource/errors"
|
|
6
5
|
|
|
@@ -8,6 +7,7 @@ require "concurrent/atomics"
|
|
|
8
7
|
require "logger"
|
|
9
8
|
require "thread"
|
|
10
9
|
require "uri"
|
|
10
|
+
require "http"
|
|
11
11
|
|
|
12
12
|
module SSE
|
|
13
13
|
#
|
|
@@ -80,6 +80,9 @@ module SSE
|
|
|
80
80
|
# proxy with the `HTTP_PROXY` or `HTTPS_PROXY` environment variable)
|
|
81
81
|
# @param logger [Logger] a Logger instance for the client to use for diagnostic output;
|
|
82
82
|
# defaults to a logger with WARN level that goes to standard output
|
|
83
|
+
# @param socket_factory [#open] (nil) an optional factory object for creating sockets,
|
|
84
|
+
# if you want to use something other than the default `TCPSocket`; it must implement
|
|
85
|
+
# `open(uri, timeout)` to return a connected `Socket`
|
|
83
86
|
# @yieldparam [Client] client the new client instance, before opening the connection
|
|
84
87
|
#
|
|
85
88
|
def initialize(uri,
|
|
@@ -90,7 +93,8 @@ module SSE
|
|
|
90
93
|
reconnect_reset_interval: DEFAULT_RECONNECT_RESET_INTERVAL,
|
|
91
94
|
last_event_id: nil,
|
|
92
95
|
proxy: nil,
|
|
93
|
-
logger: nil
|
|
96
|
+
logger: nil,
|
|
97
|
+
socket_factory: nil)
|
|
94
98
|
@uri = URI(uri)
|
|
95
99
|
@stopped = Concurrent::AtomicBoolean.new(false)
|
|
96
100
|
|
|
@@ -98,7 +102,11 @@ module SSE
|
|
|
98
102
|
@connect_timeout = connect_timeout
|
|
99
103
|
@read_timeout = read_timeout
|
|
100
104
|
@logger = logger || default_logger
|
|
101
|
-
|
|
105
|
+
http_client_options = {}
|
|
106
|
+
if socket_factory
|
|
107
|
+
http_client_options["socket_class"] = socket_factory
|
|
108
|
+
end
|
|
109
|
+
|
|
102
110
|
if proxy
|
|
103
111
|
@proxy = proxy
|
|
104
112
|
else
|
|
@@ -108,6 +116,21 @@ module SSE
|
|
|
108
116
|
end
|
|
109
117
|
end
|
|
110
118
|
|
|
119
|
+
if @proxy
|
|
120
|
+
http_client_options["proxy"] = {
|
|
121
|
+
:proxy_address => @proxy.host,
|
|
122
|
+
:proxy_port => @proxy.port
|
|
123
|
+
}
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
@http_client = HTTP::Client.new(http_client_options)
|
|
127
|
+
.timeout({
|
|
128
|
+
read: read_timeout,
|
|
129
|
+
connect: connect_timeout
|
|
130
|
+
})
|
|
131
|
+
@buffer = ""
|
|
132
|
+
@lock = Mutex.new
|
|
133
|
+
|
|
111
134
|
@backoff = Impl::Backoff.new(reconnect_time || DEFAULT_RECONNECT_TIME, MAX_RECONNECT_TIME,
|
|
112
135
|
reconnect_reset_interval: reconnect_reset_interval)
|
|
113
136
|
|
|
@@ -163,12 +186,56 @@ module SSE
|
|
|
163
186
|
#
|
|
164
187
|
def close
|
|
165
188
|
if @stopped.make_true
|
|
166
|
-
|
|
167
|
-
@cxn = nil
|
|
189
|
+
reset_http
|
|
168
190
|
end
|
|
169
191
|
end
|
|
170
192
|
|
|
171
193
|
private
|
|
194
|
+
|
|
195
|
+
def reset_http
|
|
196
|
+
@http_client.close if !@http_client.nil?
|
|
197
|
+
@cxn = nil
|
|
198
|
+
@buffer = ""
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def read_lines
|
|
202
|
+
Enumerator.new do |gen|
|
|
203
|
+
loop do
|
|
204
|
+
line = read_line
|
|
205
|
+
break if line.nil?
|
|
206
|
+
gen.yield line
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def read_line
|
|
212
|
+
loop do
|
|
213
|
+
@lock.synchronize do
|
|
214
|
+
i = @buffer.index(/[\r\n]/)
|
|
215
|
+
if !i.nil? && !(i == @buffer.length - 1 && @buffer[i] == "\r")
|
|
216
|
+
i += 1 if (@buffer[i] == "\r" && @buffer[i + 1] == "\n")
|
|
217
|
+
return @buffer.slice!(0, i + 1).force_encoding(Encoding::UTF_8)
|
|
218
|
+
end
|
|
219
|
+
end
|
|
220
|
+
return nil if !read_chunk_into_buffer
|
|
221
|
+
end
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
def read_chunk_into_buffer
|
|
225
|
+
# If @done is set, it means the Parser has signaled end of response body
|
|
226
|
+
@lock.synchronize { return false if @done }
|
|
227
|
+
begin
|
|
228
|
+
data = @cxn.readpartial
|
|
229
|
+
rescue HTTP::TimeoutError
|
|
230
|
+
# We rethrow this as our own type so the caller doesn't have to know the httprb API
|
|
231
|
+
raise Errors::ReadTimeoutError.new(@read_timeout)
|
|
232
|
+
end
|
|
233
|
+
return false if data == nil
|
|
234
|
+
@buffer << data
|
|
235
|
+
# We are piping the content through the parser so that it can handle things like chunked
|
|
236
|
+
# encoding for us. The content ends up being appended to @buffer via our callback.
|
|
237
|
+
true
|
|
238
|
+
end
|
|
172
239
|
|
|
173
240
|
def default_logger
|
|
174
241
|
log = ::Logger.new($stdout)
|
|
@@ -186,15 +253,17 @@ module SSE
|
|
|
186
253
|
# connected but before @cxn was set. Checking the variable again is a bit clunky but avoids that.
|
|
187
254
|
return if @stopped.value
|
|
188
255
|
read_stream(@cxn) if !@cxn.nil?
|
|
189
|
-
rescue
|
|
190
|
-
#
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
256
|
+
rescue => e
|
|
257
|
+
# When we deliberately close the connection, it will usually trigger an exception. The exact type
|
|
258
|
+
# of exception depends on the specific Ruby runtime. But @stopped will always be set in this case.
|
|
259
|
+
if @stopped.value
|
|
260
|
+
@logger.info { "Stream connection closed" }
|
|
261
|
+
else
|
|
262
|
+
log_and_dispatch_error(e, "Unexpected error from event source")
|
|
263
|
+
end
|
|
195
264
|
end
|
|
196
265
|
begin
|
|
197
|
-
|
|
266
|
+
reset_http
|
|
198
267
|
rescue StandardError => e
|
|
199
268
|
log_and_dispatch_error(e, "Unexpected error while closing stream")
|
|
200
269
|
end
|
|
@@ -210,36 +279,32 @@ module SSE
|
|
|
210
279
|
@logger.info { "Will retry connection after #{'%.3f' % interval} seconds" }
|
|
211
280
|
sleep(interval)
|
|
212
281
|
end
|
|
282
|
+
cxn = nil
|
|
213
283
|
begin
|
|
214
284
|
@logger.info { "Connecting to event stream at #{@uri}" }
|
|
215
|
-
cxn =
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
read_timeout: @read_timeout
|
|
220
|
-
)
|
|
221
|
-
if cxn.status == 200
|
|
285
|
+
cxn = @http_client.request("GET", @uri, {
|
|
286
|
+
headers: build_headers
|
|
287
|
+
})
|
|
288
|
+
if cxn.status.code == 200
|
|
222
289
|
content_type = cxn.headers["content-type"]
|
|
223
290
|
if content_type && content_type.start_with?("text/event-stream")
|
|
224
291
|
return cxn # we're good to proceed
|
|
225
292
|
else
|
|
226
|
-
|
|
293
|
+
reset_http
|
|
227
294
|
err = Errors::HTTPContentTypeError.new(cxn.headers["content-type"])
|
|
228
295
|
@on[:error].call(err)
|
|
229
296
|
@logger.warn { "Event source returned unexpected content type '#{cxn.headers["content-type"]}'" }
|
|
230
297
|
end
|
|
231
298
|
else
|
|
232
|
-
body = cxn.
|
|
233
|
-
|
|
234
|
-
@logger.info { "Server returned error status #{cxn.status}" }
|
|
235
|
-
err = Errors::HTTPStatusError.new(cxn.status, body)
|
|
299
|
+
body = cxn.to_s # grab the whole response body in case it has error details
|
|
300
|
+
reset_http
|
|
301
|
+
@logger.info { "Server returned error status #{cxn.status.code}" }
|
|
302
|
+
err = Errors::HTTPStatusError.new(cxn.status.code, body)
|
|
236
303
|
@on[:error].call(err)
|
|
237
304
|
end
|
|
238
|
-
rescue
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
cxn.close if !cxn.nil?
|
|
242
|
-
log_and_dispatch_error(e, "Unexpected error from event source")
|
|
305
|
+
rescue
|
|
306
|
+
reset_http
|
|
307
|
+
raise # will be handled in run_stream
|
|
243
308
|
end
|
|
244
309
|
# if unsuccessful, continue the loop to connect again
|
|
245
310
|
end
|
|
@@ -252,7 +317,7 @@ module SSE
|
|
|
252
317
|
# it can automatically reset itself if enough time passes between failures.
|
|
253
318
|
@backoff.mark_success
|
|
254
319
|
|
|
255
|
-
event_parser = Impl::EventParser.new(
|
|
320
|
+
event_parser = Impl::EventParser.new(read_lines)
|
|
256
321
|
event_parser.items.each do |item|
|
|
257
322
|
return if @stopped.value
|
|
258
323
|
case item
|
|
@@ -287,7 +352,8 @@ module SSE
|
|
|
287
352
|
def build_headers
|
|
288
353
|
h = {
|
|
289
354
|
'Accept' => 'text/event-stream',
|
|
290
|
-
'Cache-Control' => 'no-cache'
|
|
355
|
+
'Cache-Control' => 'no-cache',
|
|
356
|
+
'User-Agent' => 'ruby-eventsource'
|
|
291
357
|
}
|
|
292
358
|
h['Last-Event-Id'] = @last_id if !@last_id.nil?
|
|
293
359
|
h.merge(@headers)
|
|
@@ -38,10 +38,6 @@ module SSE
|
|
|
38
38
|
good_duration = Time.now.to_f - @last_good_time
|
|
39
39
|
@attempts = 0 if good_duration >= @reconnect_reset_interval
|
|
40
40
|
end
|
|
41
|
-
if @attempts == 0
|
|
42
|
-
@attempts += 1
|
|
43
|
-
return 0
|
|
44
|
-
end
|
|
45
41
|
@last_good_time = nil
|
|
46
42
|
target = ([@base_interval * (2 ** @attempts), @max_interval].min).to_f
|
|
47
43
|
@attempts += 1
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
require "ld-eventsource"
|
|
2
|
+
|
|
3
|
+
require "http_stub"
|
|
4
|
+
|
|
5
|
+
module SSE
|
|
6
|
+
module Impl
|
|
7
|
+
describe Backoff do
|
|
8
|
+
it "increases exponentially with jitter" do
|
|
9
|
+
initial = 1.5
|
|
10
|
+
max = 60
|
|
11
|
+
b = Backoff.new(initial, max)
|
|
12
|
+
previous = 0
|
|
13
|
+
|
|
14
|
+
for i in 1..6 do
|
|
15
|
+
interval = b.next_interval
|
|
16
|
+
expect(interval).to be > previous
|
|
17
|
+
target = initial * (2 ** (i - 1))
|
|
18
|
+
expect(interval).to be <= target
|
|
19
|
+
expect(interval).to be >= target / 2
|
|
20
|
+
previous = i
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
interval = b.next_interval
|
|
24
|
+
expect(interval).to be >= previous
|
|
25
|
+
expect(interval).to be <= max
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
it "resets to initial delay if reset threshold has elapsed" do
|
|
29
|
+
initial = 1.5
|
|
30
|
+
max = 60
|
|
31
|
+
threshold = 2
|
|
32
|
+
b = Backoff.new(initial, max, reconnect_reset_interval: threshold)
|
|
33
|
+
|
|
34
|
+
for i in 1..6 do
|
|
35
|
+
# just cause the backoff to increase quickly, don't actually do these delays
|
|
36
|
+
b.next_interval
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
b.mark_success
|
|
40
|
+
sleep(threshold + 0.001)
|
|
41
|
+
|
|
42
|
+
interval = b.next_interval
|
|
43
|
+
expect(interval).to be <= initial
|
|
44
|
+
expect(interval).to be >= initial / 2
|
|
45
|
+
|
|
46
|
+
interval = b.next_interval # make sure it continues increasing after that
|
|
47
|
+
expect(interval).to be <= (initial * 2)
|
|
48
|
+
expect(interval).to be >= initial
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
data/spec/client_spec.rb
CHANGED
|
@@ -1,6 +1,4 @@
|
|
|
1
1
|
require "ld-eventsource"
|
|
2
|
-
require "socketry"
|
|
3
|
-
require "http_stub"
|
|
4
2
|
|
|
5
3
|
#
|
|
6
4
|
# End-to-end tests of the SSE client against a real server
|
|
@@ -62,8 +60,11 @@ EOT
|
|
|
62
60
|
expect(received_req.header).to eq({
|
|
63
61
|
"accept" => ["text/event-stream"],
|
|
64
62
|
"cache-control" => ["no-cache"],
|
|
65
|
-
"host" => ["127.0.0.1"],
|
|
66
|
-
"authorization" => ["secret"]
|
|
63
|
+
"host" => ["127.0.0.1:" + server.port.to_s],
|
|
64
|
+
"authorization" => ["secret"],
|
|
65
|
+
"user-agent" => ["ruby-eventsource"],
|
|
66
|
+
"content-length" => ["0"],
|
|
67
|
+
"connection" => ["close"]
|
|
67
68
|
})
|
|
68
69
|
end
|
|
69
70
|
end
|
|
@@ -85,9 +86,12 @@ EOT
|
|
|
85
86
|
expect(received_req.header).to eq({
|
|
86
87
|
"accept" => ["text/event-stream"],
|
|
87
88
|
"cache-control" => ["no-cache"],
|
|
88
|
-
"host" => ["127.0.0.1"],
|
|
89
|
+
"host" => ["127.0.0.1:" + server.port.to_s],
|
|
89
90
|
"authorization" => ["secret"],
|
|
90
|
-
"last-event-id" => [id]
|
|
91
|
+
"last-event-id" => [id],
|
|
92
|
+
"user-agent" => ["ruby-eventsource"],
|
|
93
|
+
"content-length" => ["0"],
|
|
94
|
+
"connection" => ["close"]
|
|
91
95
|
})
|
|
92
96
|
end
|
|
93
97
|
end
|
|
@@ -112,6 +116,29 @@ EOT
|
|
|
112
116
|
end
|
|
113
117
|
end
|
|
114
118
|
|
|
119
|
+
it "does not trigger an error when stream is closed" do
|
|
120
|
+
events_body = simple_event_1_text + simple_event_2_text
|
|
121
|
+
with_server do |server|
|
|
122
|
+
server.setup_response("/") do |req,res|
|
|
123
|
+
send_stream_content(res, events_body, keep_open: true)
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
event_sink = Queue.new
|
|
127
|
+
error_sink = Queue.new
|
|
128
|
+
client = subject.new(server.base_uri) do |c|
|
|
129
|
+
c.on_event { |event| event_sink << event }
|
|
130
|
+
c.on_error { |error| error_sink << error }
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
with_client(client) do |client|
|
|
134
|
+
event_sink.pop # wait till we have definitely started reading the stream
|
|
135
|
+
client.close
|
|
136
|
+
sleep 0.25 # there's no way to really know when the stream thread has finished
|
|
137
|
+
expect(error_sink.empty?).to be true
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
|
|
115
142
|
it "reconnects after error response" do
|
|
116
143
|
events_body = simple_event_1_text
|
|
117
144
|
with_server do |server|
|
|
@@ -307,7 +334,7 @@ EOT
|
|
|
307
334
|
expect(event_sink.pop).to eq(simple_event_1)
|
|
308
335
|
if i > 0
|
|
309
336
|
interval = request_times[i] - request_end_times[i - 1]
|
|
310
|
-
expect(interval).to be <= initial_interval
|
|
337
|
+
expect(interval).to be <= (initial_interval + 0.1)
|
|
311
338
|
end
|
|
312
339
|
end
|
|
313
340
|
end
|
|
@@ -339,7 +366,63 @@ EOT
|
|
|
339
366
|
with_client(client) do |client|
|
|
340
367
|
expect(event_sink.pop).to eq(simple_event_1)
|
|
341
368
|
interval = request_times[1] - request_times[0]
|
|
342
|
-
expect(interval).to be <
|
|
369
|
+
expect(interval).to be < 0.5
|
|
370
|
+
end
|
|
371
|
+
end
|
|
372
|
+
end
|
|
373
|
+
|
|
374
|
+
it "connects to HTTP server through proxy" do
|
|
375
|
+
events_body = simple_event_1_text
|
|
376
|
+
with_server do |server|
|
|
377
|
+
server.setup_response("/") do |req,res|
|
|
378
|
+
send_stream_content(res, events_body, keep_open: false)
|
|
379
|
+
end
|
|
380
|
+
with_server(StubProxyServer.new) do |proxy|
|
|
381
|
+
event_sink = Queue.new
|
|
382
|
+
client = subject.new(server.base_uri, proxy: proxy.base_uri) do |c|
|
|
383
|
+
c.on_event { |event| event_sink << event }
|
|
384
|
+
end
|
|
385
|
+
|
|
386
|
+
with_client(client) do |client|
|
|
387
|
+
expect(event_sink.pop).to eq(simple_event_1)
|
|
388
|
+
expect(proxy.request_count).to eq(1)
|
|
389
|
+
end
|
|
390
|
+
end
|
|
391
|
+
end
|
|
392
|
+
end
|
|
393
|
+
|
|
394
|
+
it "resets read timeout between events" do
|
|
395
|
+
event_body = simple_event_1_text
|
|
396
|
+
with_server do |server|
|
|
397
|
+
attempt = 0
|
|
398
|
+
server.setup_response("/") do |req,res|
|
|
399
|
+
attempt += 1
|
|
400
|
+
if attempt == 1
|
|
401
|
+
stream = send_stream_content(res, event_body, keep_open: true)
|
|
402
|
+
Thread.new do
|
|
403
|
+
2.times {
|
|
404
|
+
# write within timeout interval
|
|
405
|
+
sleep(0.75)
|
|
406
|
+
stream.write(event_body)
|
|
407
|
+
}
|
|
408
|
+
# cause timeout
|
|
409
|
+
sleep(1.25)
|
|
410
|
+
end
|
|
411
|
+
elsif attempt == 2
|
|
412
|
+
send_stream_content(res, event_body, keep_open: false)
|
|
413
|
+
end
|
|
414
|
+
end
|
|
415
|
+
|
|
416
|
+
event_sink = Queue.new
|
|
417
|
+
client = subject.new(server.base_uri, reconnect_time: reconnect_asap, read_timeout: 1) do |c|
|
|
418
|
+
c.on_event { |event| event_sink << event }
|
|
419
|
+
end
|
|
420
|
+
|
|
421
|
+
with_client(client) do |client|
|
|
422
|
+
4.times {
|
|
423
|
+
expect(event_sink.pop).to eq(simple_event_1)
|
|
424
|
+
}
|
|
425
|
+
expect(attempt).to eq 2
|
|
343
426
|
end
|
|
344
427
|
end
|
|
345
428
|
end
|
data/spec/http_stub.rb
CHANGED
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: ld-eventsource
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version:
|
|
4
|
+
version: 2.0.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- LaunchDarkly
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date:
|
|
11
|
+
date: 2021-01-26 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: bundler
|
|
@@ -39,33 +39,33 @@ dependencies:
|
|
|
39
39
|
- !ruby/object:Gem::Version
|
|
40
40
|
version: '3.2'
|
|
41
41
|
- !ruby/object:Gem::Dependency
|
|
42
|
-
name:
|
|
42
|
+
name: rspec_junit_formatter
|
|
43
43
|
requirement: !ruby/object:Gem::Requirement
|
|
44
44
|
requirements:
|
|
45
45
|
- - "~>"
|
|
46
46
|
- !ruby/object:Gem::Version
|
|
47
|
-
version:
|
|
47
|
+
version: 0.3.0
|
|
48
48
|
type: :development
|
|
49
49
|
prerelease: false
|
|
50
50
|
version_requirements: !ruby/object:Gem::Requirement
|
|
51
51
|
requirements:
|
|
52
52
|
- - "~>"
|
|
53
53
|
- !ruby/object:Gem::Version
|
|
54
|
-
version:
|
|
54
|
+
version: 0.3.0
|
|
55
55
|
- !ruby/object:Gem::Dependency
|
|
56
|
-
name:
|
|
56
|
+
name: webrick
|
|
57
57
|
requirement: !ruby/object:Gem::Requirement
|
|
58
58
|
requirements:
|
|
59
59
|
- - "~>"
|
|
60
60
|
- !ruby/object:Gem::Version
|
|
61
|
-
version:
|
|
61
|
+
version: '1.7'
|
|
62
62
|
type: :development
|
|
63
63
|
prerelease: false
|
|
64
64
|
version_requirements: !ruby/object:Gem::Requirement
|
|
65
65
|
requirements:
|
|
66
66
|
- - "~>"
|
|
67
67
|
- !ruby/object:Gem::Version
|
|
68
|
-
version:
|
|
68
|
+
version: '1.7'
|
|
69
69
|
- !ruby/object:Gem::Dependency
|
|
70
70
|
name: concurrent-ruby
|
|
71
71
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -81,33 +81,19 @@ dependencies:
|
|
|
81
81
|
- !ruby/object:Gem::Version
|
|
82
82
|
version: '1.0'
|
|
83
83
|
- !ruby/object:Gem::Dependency
|
|
84
|
-
name:
|
|
85
|
-
requirement: !ruby/object:Gem::Requirement
|
|
86
|
-
requirements:
|
|
87
|
-
- - "~>"
|
|
88
|
-
- !ruby/object:Gem::Version
|
|
89
|
-
version: 0.4.5
|
|
90
|
-
type: :runtime
|
|
91
|
-
prerelease: false
|
|
92
|
-
version_requirements: !ruby/object:Gem::Requirement
|
|
93
|
-
requirements:
|
|
94
|
-
- - "~>"
|
|
95
|
-
- !ruby/object:Gem::Version
|
|
96
|
-
version: 0.4.5
|
|
97
|
-
- !ruby/object:Gem::Dependency
|
|
98
|
-
name: socketry
|
|
84
|
+
name: http
|
|
99
85
|
requirement: !ruby/object:Gem::Requirement
|
|
100
86
|
requirements:
|
|
101
87
|
- - "~>"
|
|
102
88
|
- !ruby/object:Gem::Version
|
|
103
|
-
version:
|
|
89
|
+
version: 4.4.1
|
|
104
90
|
type: :runtime
|
|
105
91
|
prerelease: false
|
|
106
92
|
version_requirements: !ruby/object:Gem::Requirement
|
|
107
93
|
requirements:
|
|
108
94
|
- - "~>"
|
|
109
95
|
- !ruby/object:Gem::Version
|
|
110
|
-
version:
|
|
96
|
+
version: 4.4.1
|
|
111
97
|
description: LaunchDarkly SSE client for Ruby
|
|
112
98
|
email:
|
|
113
99
|
- team@launchdarkly.com
|
|
@@ -117,12 +103,12 @@ extra_rdoc_files: []
|
|
|
117
103
|
files:
|
|
118
104
|
- ".circleci/config.yml"
|
|
119
105
|
- ".gitignore"
|
|
106
|
+
- ".ldrelease/config.yml"
|
|
120
107
|
- CHANGELOG.md
|
|
121
108
|
- Gemfile
|
|
122
109
|
- Gemfile.lock
|
|
123
110
|
- LICENSE
|
|
124
111
|
- README.md
|
|
125
|
-
- Rakefile
|
|
126
112
|
- ld-eventsource.gemspec
|
|
127
113
|
- lib/ld-eventsource.rb
|
|
128
114
|
- lib/ld-eventsource/client.rb
|
|
@@ -130,14 +116,13 @@ files:
|
|
|
130
116
|
- lib/ld-eventsource/events.rb
|
|
131
117
|
- lib/ld-eventsource/impl/backoff.rb
|
|
132
118
|
- lib/ld-eventsource/impl/event_parser.rb
|
|
133
|
-
- lib/ld-eventsource/impl/streaming_http.rb
|
|
134
119
|
- lib/ld-eventsource/version.rb
|
|
135
120
|
- scripts/gendocs.sh
|
|
136
121
|
- scripts/release.sh
|
|
122
|
+
- spec/backoff_spec.rb
|
|
137
123
|
- spec/client_spec.rb
|
|
138
124
|
- spec/event_parser_spec.rb
|
|
139
125
|
- spec/http_stub.rb
|
|
140
|
-
- spec/streaming_http_spec.rb
|
|
141
126
|
homepage: https://github.com/launchdarkly/ruby-eventsource
|
|
142
127
|
licenses:
|
|
143
128
|
- Apache-2.0
|
|
@@ -158,12 +143,12 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
158
143
|
version: '0'
|
|
159
144
|
requirements: []
|
|
160
145
|
rubyforge_project:
|
|
161
|
-
rubygems_version: 2.
|
|
146
|
+
rubygems_version: 2.5.2.3
|
|
162
147
|
signing_key:
|
|
163
148
|
specification_version: 4
|
|
164
149
|
summary: LaunchDarkly SSE client
|
|
165
150
|
test_files:
|
|
151
|
+
- spec/backoff_spec.rb
|
|
166
152
|
- spec/client_spec.rb
|
|
167
153
|
- spec/event_parser_spec.rb
|
|
168
154
|
- spec/http_stub.rb
|
|
169
|
-
- spec/streaming_http_spec.rb
|
data/Rakefile
DELETED
|
@@ -1,222 +0,0 @@
|
|
|
1
|
-
require "ld-eventsource/errors"
|
|
2
|
-
|
|
3
|
-
require "concurrent/atomics"
|
|
4
|
-
require "http_tools"
|
|
5
|
-
require "socketry"
|
|
6
|
-
|
|
7
|
-
module SSE
|
|
8
|
-
module Impl
|
|
9
|
-
#
|
|
10
|
-
# Wrapper around a socket providing a simplified HTTP request-response cycle including streaming.
|
|
11
|
-
# The socket is created and managed by Socketry, which we use so that we can have a read timeout.
|
|
12
|
-
#
|
|
13
|
-
class StreamingHTTPConnection
|
|
14
|
-
attr_reader :status, :headers
|
|
15
|
-
|
|
16
|
-
#
|
|
17
|
-
# Opens a new connection.
|
|
18
|
-
#
|
|
19
|
-
# @param [String] uri the URI to connect o
|
|
20
|
-
# @param [String] proxy the proxy server URI, if any
|
|
21
|
-
# @param [Hash] headers request headers
|
|
22
|
-
# @param [Float] connect_timeout connection timeout
|
|
23
|
-
# @param [Float] read_timeout read timeout
|
|
24
|
-
#
|
|
25
|
-
def initialize(uri, proxy: nil, headers: {}, connect_timeout: nil, read_timeout: nil)
|
|
26
|
-
@socket = HTTPConnectionFactory.connect(uri, proxy, connect_timeout, read_timeout)
|
|
27
|
-
@socket.write(build_request(uri, headers))
|
|
28
|
-
@reader = HTTPResponseReader.new(@socket, read_timeout)
|
|
29
|
-
@status = @reader.status
|
|
30
|
-
@headers = @reader.headers
|
|
31
|
-
@closed = Concurrent::AtomicBoolean.new(false)
|
|
32
|
-
end
|
|
33
|
-
|
|
34
|
-
#
|
|
35
|
-
# Closes the connection.
|
|
36
|
-
#
|
|
37
|
-
def close
|
|
38
|
-
if @closed.make_true
|
|
39
|
-
@socket.close if @socket
|
|
40
|
-
@socket = nil
|
|
41
|
-
end
|
|
42
|
-
end
|
|
43
|
-
|
|
44
|
-
#
|
|
45
|
-
# Generator that returns one line of the response body at a time (delimited by \r, \n,
|
|
46
|
-
# or \r\n) until the response is fully consumed or the socket is closed.
|
|
47
|
-
#
|
|
48
|
-
def read_lines
|
|
49
|
-
@reader.read_lines
|
|
50
|
-
end
|
|
51
|
-
|
|
52
|
-
#
|
|
53
|
-
# Consumes the entire response body and returns it.
|
|
54
|
-
#
|
|
55
|
-
# @return [String] the response body
|
|
56
|
-
#
|
|
57
|
-
def read_all
|
|
58
|
-
@reader.read_all
|
|
59
|
-
end
|
|
60
|
-
|
|
61
|
-
private
|
|
62
|
-
|
|
63
|
-
# Build an HTTP request line and headers.
|
|
64
|
-
def build_request(uri, headers)
|
|
65
|
-
ret = "GET #{uri.request_uri} HTTP/1.1\r\n"
|
|
66
|
-
ret << "Host: #{uri.host}\r\n"
|
|
67
|
-
headers.each { |k, v|
|
|
68
|
-
ret << "#{k}: #{v}\r\n"
|
|
69
|
-
}
|
|
70
|
-
ret + "\r\n"
|
|
71
|
-
end
|
|
72
|
-
end
|
|
73
|
-
|
|
74
|
-
#
|
|
75
|
-
# Used internally to send the HTTP request, including the proxy dialogue if necessary.
|
|
76
|
-
# @private
|
|
77
|
-
#
|
|
78
|
-
class HTTPConnectionFactory
|
|
79
|
-
def self.connect(uri, proxy, connect_timeout, read_timeout)
|
|
80
|
-
if !proxy
|
|
81
|
-
return open_socket(uri, connect_timeout)
|
|
82
|
-
end
|
|
83
|
-
|
|
84
|
-
socket = open_socket(proxy, connect_timeout)
|
|
85
|
-
socket.write(build_proxy_request(uri, proxy))
|
|
86
|
-
|
|
87
|
-
# temporarily create a reader just for the proxy connect response
|
|
88
|
-
proxy_reader = HTTPResponseReader.new(socket, read_timeout)
|
|
89
|
-
if proxy_reader.status != 200
|
|
90
|
-
raise Errors::HTTPProxyError.new(proxy_reader.status)
|
|
91
|
-
end
|
|
92
|
-
|
|
93
|
-
# start using TLS at this point if appropriate
|
|
94
|
-
if uri.scheme.downcase == 'https'
|
|
95
|
-
wrap_socket_in_ssl_socket(socket)
|
|
96
|
-
else
|
|
97
|
-
socket
|
|
98
|
-
end
|
|
99
|
-
end
|
|
100
|
-
|
|
101
|
-
private
|
|
102
|
-
|
|
103
|
-
def self.open_socket(uri, connect_timeout)
|
|
104
|
-
if uri.scheme.downcase == 'https'
|
|
105
|
-
Socketry::SSL::Socket.connect(uri.host, uri.port, timeout: connect_timeout)
|
|
106
|
-
else
|
|
107
|
-
Socketry::TCP::Socket.connect(uri.host, uri.port, timeout: connect_timeout)
|
|
108
|
-
end
|
|
109
|
-
end
|
|
110
|
-
|
|
111
|
-
# Build a proxy connection header.
|
|
112
|
-
def self.build_proxy_request(uri, proxy)
|
|
113
|
-
ret = "CONNECT #{uri.host}:#{uri.port} HTTP/1.1\r\n"
|
|
114
|
-
ret << "Host: #{uri.host}:#{uri.port}\r\n"
|
|
115
|
-
if proxy.user || proxy.password
|
|
116
|
-
encoded_credentials = Base64.strict_encode64([proxy.user || '', proxy.password || ''].join(":"))
|
|
117
|
-
ret << "Proxy-Authorization: Basic #{encoded_credentials}\r\n"
|
|
118
|
-
end
|
|
119
|
-
ret << "\r\n"
|
|
120
|
-
ret
|
|
121
|
-
end
|
|
122
|
-
|
|
123
|
-
def self.wrap_socket_in_ssl_socket(socket)
|
|
124
|
-
io = IO.try_convert(socket)
|
|
125
|
-
ssl_sock = OpenSSL::SSL::SSLSocket.new(io, OpenSSL::SSL::SSLContext.new)
|
|
126
|
-
ssl_sock.connect
|
|
127
|
-
Socketry::SSL::Socket.new.from_socket(ssl_sock)
|
|
128
|
-
end
|
|
129
|
-
end
|
|
130
|
-
|
|
131
|
-
#
|
|
132
|
-
# Used internally to read the HTTP response, either all at once or as a stream of text lines.
|
|
133
|
-
# Incoming data is fed into an instance of HTTPTools::Parser, which gives us the header and
|
|
134
|
-
# chunks of the body via callbacks.
|
|
135
|
-
# @private
|
|
136
|
-
#
|
|
137
|
-
class HTTPResponseReader
|
|
138
|
-
DEFAULT_CHUNK_SIZE = 10000
|
|
139
|
-
|
|
140
|
-
attr_reader :status, :headers
|
|
141
|
-
|
|
142
|
-
def initialize(socket, read_timeout)
|
|
143
|
-
@socket = socket
|
|
144
|
-
@read_timeout = read_timeout
|
|
145
|
-
@parser = HTTPTools::Parser.new
|
|
146
|
-
@buffer = ""
|
|
147
|
-
@done = false
|
|
148
|
-
@lock = Mutex.new
|
|
149
|
-
|
|
150
|
-
# Provide callbacks for the Parser to give us the headers and body. This has to be done
|
|
151
|
-
# before we start piping any data into the parser.
|
|
152
|
-
have_headers = false
|
|
153
|
-
@parser.on(:header) do
|
|
154
|
-
have_headers = true
|
|
155
|
-
end
|
|
156
|
-
@parser.on(:stream) do |data|
|
|
157
|
-
@lock.synchronize { @buffer << data } # synchronize because we're called from another thread in Socketry
|
|
158
|
-
end
|
|
159
|
-
@parser.on(:finish) do
|
|
160
|
-
@lock.synchronize { @done = true }
|
|
161
|
-
end
|
|
162
|
-
|
|
163
|
-
# Block until the status code and headers have been successfully read.
|
|
164
|
-
while !have_headers
|
|
165
|
-
raise EOFError if !read_chunk_into_buffer
|
|
166
|
-
end
|
|
167
|
-
@headers = Hash[@parser.header.map { |k,v| [k.downcase, v] }]
|
|
168
|
-
@status = @parser.status_code
|
|
169
|
-
end
|
|
170
|
-
|
|
171
|
-
def read_lines
|
|
172
|
-
Enumerator.new do |gen|
|
|
173
|
-
loop do
|
|
174
|
-
line = read_line
|
|
175
|
-
break if line.nil?
|
|
176
|
-
gen.yield line
|
|
177
|
-
end
|
|
178
|
-
end
|
|
179
|
-
end
|
|
180
|
-
|
|
181
|
-
def read_all
|
|
182
|
-
while read_chunk_into_buffer
|
|
183
|
-
end
|
|
184
|
-
@buffer
|
|
185
|
-
end
|
|
186
|
-
|
|
187
|
-
private
|
|
188
|
-
|
|
189
|
-
# Attempt to read some more data from the socket. Return true if successful, false if EOF.
|
|
190
|
-
# A read timeout will result in an exception from Socketry's readpartial method.
|
|
191
|
-
def read_chunk_into_buffer
|
|
192
|
-
# If @done is set, it means the Parser has signaled end of response body
|
|
193
|
-
@lock.synchronize { return false if @done }
|
|
194
|
-
begin
|
|
195
|
-
data = @socket.readpartial(DEFAULT_CHUNK_SIZE, timeout: @read_timeout)
|
|
196
|
-
rescue Socketry::TimeoutError
|
|
197
|
-
# We rethrow this as our own type so the caller doesn't have to know the Socketry API
|
|
198
|
-
raise Errors::ReadTimeoutError.new(@read_timeout)
|
|
199
|
-
end
|
|
200
|
-
return false if data == :eof
|
|
201
|
-
@parser << data
|
|
202
|
-
# We are piping the content through the parser so that it can handle things like chunked
|
|
203
|
-
# encoding for us. The content ends up being appended to @buffer via our callback.
|
|
204
|
-
true
|
|
205
|
-
end
|
|
206
|
-
|
|
207
|
-
# Extract the next line of text from the read buffer, refilling the buffer as needed.
|
|
208
|
-
def read_line
|
|
209
|
-
loop do
|
|
210
|
-
@lock.synchronize do
|
|
211
|
-
i = @buffer.index(/[\r\n]/)
|
|
212
|
-
if !i.nil?
|
|
213
|
-
i += 1 if (@buffer[i] == "\r" && i < @buffer.length - 1 && @buffer[i + 1] == "\n")
|
|
214
|
-
return @buffer.slice!(0, i + 1).force_encoding(Encoding::UTF_8)
|
|
215
|
-
end
|
|
216
|
-
end
|
|
217
|
-
return nil if !read_chunk_into_buffer
|
|
218
|
-
end
|
|
219
|
-
end
|
|
220
|
-
end
|
|
221
|
-
end
|
|
222
|
-
end
|
data/spec/streaming_http_spec.rb
DELETED
|
@@ -1,263 +0,0 @@
|
|
|
1
|
-
require "ld-eventsource/impl/streaming_http"
|
|
2
|
-
require "socketry"
|
|
3
|
-
require "http_stub"
|
|
4
|
-
|
|
5
|
-
#
|
|
6
|
-
# End-to-end tests of HTTP requests against a real server
|
|
7
|
-
#
|
|
8
|
-
describe SSE::Impl::StreamingHTTPConnection do
|
|
9
|
-
subject { SSE::Impl::StreamingHTTPConnection }
|
|
10
|
-
|
|
11
|
-
def with_connection(cxn)
|
|
12
|
-
begin
|
|
13
|
-
yield cxn
|
|
14
|
-
ensure
|
|
15
|
-
cxn.close
|
|
16
|
-
end
|
|
17
|
-
end
|
|
18
|
-
|
|
19
|
-
it "makes HTTP connection and sends request" do
|
|
20
|
-
with_server do |server|
|
|
21
|
-
requests = Queue.new
|
|
22
|
-
server.setup_response("/foo") do |req,res|
|
|
23
|
-
requests << req
|
|
24
|
-
res.status = 200
|
|
25
|
-
end
|
|
26
|
-
headers = {
|
|
27
|
-
"Accept" => "text/plain"
|
|
28
|
-
}
|
|
29
|
-
with_connection(subject.new(server.base_uri.merge("/foo?bar"), headers: headers)) do
|
|
30
|
-
received_req = requests.pop
|
|
31
|
-
expect(received_req.unparsed_uri).to eq("/foo?bar")
|
|
32
|
-
expect(received_req.header).to eq({
|
|
33
|
-
"accept" => ["text/plain"],
|
|
34
|
-
"host" => [server.base_uri.host]
|
|
35
|
-
})
|
|
36
|
-
end
|
|
37
|
-
end
|
|
38
|
-
end
|
|
39
|
-
|
|
40
|
-
it "receives response status" do
|
|
41
|
-
with_server do |server|
|
|
42
|
-
server.setup_response("/foo") do |req,res|
|
|
43
|
-
res.status = 204
|
|
44
|
-
end
|
|
45
|
-
with_connection(subject.new(server.base_uri.merge("/foo"))) do |cxn|
|
|
46
|
-
expect(cxn.status).to eq(204)
|
|
47
|
-
end
|
|
48
|
-
end
|
|
49
|
-
end
|
|
50
|
-
|
|
51
|
-
it "receives response headers" do
|
|
52
|
-
with_server do |server|
|
|
53
|
-
server.setup_response("/foo") do |req,res|
|
|
54
|
-
res["Content-Type"] = "application/json"
|
|
55
|
-
end
|
|
56
|
-
with_connection(subject.new(server.base_uri.merge("/foo"))) do |cxn|
|
|
57
|
-
expect(cxn.headers["content-type"]).to eq("application/json")
|
|
58
|
-
end
|
|
59
|
-
end
|
|
60
|
-
end
|
|
61
|
-
|
|
62
|
-
it "can read response as lines" do
|
|
63
|
-
body = <<-EOT
|
|
64
|
-
This is
|
|
65
|
-
a response
|
|
66
|
-
EOT
|
|
67
|
-
with_server do |server|
|
|
68
|
-
server.setup_response("/foo") do |req,res|
|
|
69
|
-
res.body = body
|
|
70
|
-
end
|
|
71
|
-
with_connection(subject.new(server.base_uri.merge("/foo"))) do |cxn|
|
|
72
|
-
lines = cxn.read_lines
|
|
73
|
-
expect(lines.next).to eq("This is\n")
|
|
74
|
-
expect(lines.next).to eq("a response\n")
|
|
75
|
-
end
|
|
76
|
-
end
|
|
77
|
-
end
|
|
78
|
-
|
|
79
|
-
it "can read entire response body" do
|
|
80
|
-
body = <<-EOT
|
|
81
|
-
This is
|
|
82
|
-
a response
|
|
83
|
-
EOT
|
|
84
|
-
with_server do |server|
|
|
85
|
-
server.setup_response("/foo") do |req,res|
|
|
86
|
-
res.body = body
|
|
87
|
-
end
|
|
88
|
-
with_connection(subject.new(server.base_uri.merge("/foo"))) do |cxn|
|
|
89
|
-
read_body = cxn.read_all
|
|
90
|
-
expect(read_body).to eq("This is\na response\n")
|
|
91
|
-
end
|
|
92
|
-
end
|
|
93
|
-
end
|
|
94
|
-
|
|
95
|
-
it "enforces read timeout" do
|
|
96
|
-
with_server do |server|
|
|
97
|
-
server.setup_response("/") do |req,res|
|
|
98
|
-
sleep(2)
|
|
99
|
-
res.status = 200
|
|
100
|
-
end
|
|
101
|
-
expect { subject.new(server.base_uri, read_timeout: 0.25) }.to raise_error(SSE::Errors::ReadTimeoutError)
|
|
102
|
-
end
|
|
103
|
-
end
|
|
104
|
-
|
|
105
|
-
it "connects to HTTP server through proxy" do
|
|
106
|
-
body = "hi"
|
|
107
|
-
with_server do |server|
|
|
108
|
-
server.setup_response("/") do |req,res|
|
|
109
|
-
res.body = body
|
|
110
|
-
end
|
|
111
|
-
with_server(StubProxyServer.new) do |proxy|
|
|
112
|
-
with_connection(subject.new(server.base_uri, proxy: proxy.base_uri)) do |cxn|
|
|
113
|
-
read_body = cxn.read_all
|
|
114
|
-
expect(read_body).to eq("hi")
|
|
115
|
-
expect(proxy.request_count).to eq(1)
|
|
116
|
-
end
|
|
117
|
-
end
|
|
118
|
-
end
|
|
119
|
-
end
|
|
120
|
-
|
|
121
|
-
it "throws error if proxy responds with error status" do
|
|
122
|
-
with_server do |server|
|
|
123
|
-
server.setup_response("/") do |req,res|
|
|
124
|
-
res.body = body
|
|
125
|
-
end
|
|
126
|
-
with_server(StubProxyServer.new) do |proxy|
|
|
127
|
-
proxy.connect_status = 403
|
|
128
|
-
expect { subject.new(server.base_uri, proxy: proxy.base_uri) }.to raise_error(SSE::Errors::HTTPProxyError)
|
|
129
|
-
end
|
|
130
|
-
end
|
|
131
|
-
end
|
|
132
|
-
|
|
133
|
-
# The following 2 tests were originally written to connect to an embedded HTTPS server made with
|
|
134
|
-
# WEBrick. Unfortunately, some unknown problem prevents WEBrick's self-signed certificate feature
|
|
135
|
-
# from working in JRuby 9.1 (but not in any other Ruby version). Therefore these tests currently
|
|
136
|
-
# hit an external URL.
|
|
137
|
-
|
|
138
|
-
it "connects to HTTPS server" do
|
|
139
|
-
with_connection(subject.new(URI("https://app.launchdarkly.com"))) do |cxn|
|
|
140
|
-
expect(cxn.status).to eq 200
|
|
141
|
-
end
|
|
142
|
-
end
|
|
143
|
-
|
|
144
|
-
it "connects to HTTPS server through proxy" do
|
|
145
|
-
with_server(StubProxyServer.new) do |proxy|
|
|
146
|
-
with_connection(subject.new(URI("https://app.launchdarkly.com"), proxy: proxy.base_uri)) do |cxn|
|
|
147
|
-
expect(cxn.status).to eq 200
|
|
148
|
-
expect(proxy.request_count).to eq(1)
|
|
149
|
-
end
|
|
150
|
-
end
|
|
151
|
-
end
|
|
152
|
-
end
|
|
153
|
-
|
|
154
|
-
#
|
|
155
|
-
# Tests of response parsing functionality without a real HTTP request
|
|
156
|
-
#
|
|
157
|
-
describe SSE::Impl::HTTPResponseReader do
|
|
158
|
-
subject { SSE::Impl::HTTPResponseReader }
|
|
159
|
-
|
|
160
|
-
let(:simple_response) { <<-EOT
|
|
161
|
-
HTTP/1.1 200 OK
|
|
162
|
-
Cache-Control: no-cache
|
|
163
|
-
Content-Type: text/event-stream
|
|
164
|
-
|
|
165
|
-
line1\r
|
|
166
|
-
line2
|
|
167
|
-
\r
|
|
168
|
-
EOT
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
def make_chunks(str)
|
|
172
|
-
# arbitrarily split content into 5-character blocks
|
|
173
|
-
str.scan(/.{1,5}/m).to_enum
|
|
174
|
-
end
|
|
175
|
-
|
|
176
|
-
def mock_socket_without_timeout(chunks)
|
|
177
|
-
mock_socket(chunks) { :eof }
|
|
178
|
-
end
|
|
179
|
-
|
|
180
|
-
def mock_socket_with_timeout(chunks)
|
|
181
|
-
mock_socket(chunks) { raise Socketry::TimeoutError }
|
|
182
|
-
end
|
|
183
|
-
|
|
184
|
-
def mock_socket(chunks)
|
|
185
|
-
sock = double
|
|
186
|
-
allow(sock).to receive(:readpartial) do
|
|
187
|
-
begin
|
|
188
|
-
chunks.next
|
|
189
|
-
rescue StopIteration
|
|
190
|
-
yield
|
|
191
|
-
end
|
|
192
|
-
end
|
|
193
|
-
sock
|
|
194
|
-
end
|
|
195
|
-
|
|
196
|
-
it "parses status code" do
|
|
197
|
-
socket = mock_socket_without_timeout(make_chunks(simple_response))
|
|
198
|
-
reader = subject.new(socket, 0)
|
|
199
|
-
expect(reader.status).to eq(200)
|
|
200
|
-
end
|
|
201
|
-
|
|
202
|
-
it "parses headers" do
|
|
203
|
-
socket = mock_socket_without_timeout(make_chunks(simple_response))
|
|
204
|
-
reader = subject.new(socket, 0)
|
|
205
|
-
expect(reader.headers).to eq({
|
|
206
|
-
'cache-control' => 'no-cache',
|
|
207
|
-
'content-type' => 'text/event-stream'
|
|
208
|
-
})
|
|
209
|
-
end
|
|
210
|
-
|
|
211
|
-
it "can read entire response body" do
|
|
212
|
-
socket = mock_socket_without_timeout(make_chunks(simple_response))
|
|
213
|
-
reader = subject.new(socket, 0)
|
|
214
|
-
expect(reader.read_all).to eq("line1\r\nline2\n\r\n")
|
|
215
|
-
end
|
|
216
|
-
|
|
217
|
-
it "can read response body as lines" do
|
|
218
|
-
socket = mock_socket_without_timeout(make_chunks(simple_response))
|
|
219
|
-
reader = subject.new(socket, 0)
|
|
220
|
-
expect(reader.read_lines.to_a).to eq([
|
|
221
|
-
"line1\r\n",
|
|
222
|
-
"line2\n",
|
|
223
|
-
"\r\n"
|
|
224
|
-
])
|
|
225
|
-
end
|
|
226
|
-
|
|
227
|
-
it "handles chunked encoding" do
|
|
228
|
-
chunked_response = <<-EOT
|
|
229
|
-
HTTP/1.1 200 OK
|
|
230
|
-
Content-Type: text/plain
|
|
231
|
-
Transfer-Encoding: chunked
|
|
232
|
-
|
|
233
|
-
6\r
|
|
234
|
-
things\r
|
|
235
|
-
A\r
|
|
236
|
-
and stuff\r
|
|
237
|
-
0\r
|
|
238
|
-
\r
|
|
239
|
-
EOT
|
|
240
|
-
socket = mock_socket_without_timeout(make_chunks(chunked_response))
|
|
241
|
-
reader = subject.new(socket, 0)
|
|
242
|
-
expect(reader.read_all).to eq("things and stuff")
|
|
243
|
-
end
|
|
244
|
-
|
|
245
|
-
it "raises error if response ends without complete headers" do
|
|
246
|
-
malformed_response = <<-EOT
|
|
247
|
-
HTTP/1.1 200 OK
|
|
248
|
-
Cache-Control: no-cache
|
|
249
|
-
EOT
|
|
250
|
-
socket = mock_socket_without_timeout(make_chunks(malformed_response))
|
|
251
|
-
expect { subject.new(socket, 0) }.to raise_error(EOFError)
|
|
252
|
-
end
|
|
253
|
-
|
|
254
|
-
it "throws timeout if thrown by socket read" do
|
|
255
|
-
socket = mock_socket_with_timeout(make_chunks(simple_response))
|
|
256
|
-
reader = subject.new(socket, 0)
|
|
257
|
-
lines = reader.read_lines
|
|
258
|
-
lines.next
|
|
259
|
-
lines.next
|
|
260
|
-
lines.next
|
|
261
|
-
expect { lines.next }.to raise_error(SSE::Errors::ReadTimeoutError)
|
|
262
|
-
end
|
|
263
|
-
end
|