ldclient-rb 4.0.0 → 5.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 +4 -4
- data/.circleci/config.yml +11 -4
- data/CHANGELOG.md +9 -0
- data/README.md +7 -3
- data/ldclient-rb.gemspec +6 -24
- data/lib/ldclient-rb.rb +1 -0
- data/lib/ldclient-rb/events.rb +13 -7
- data/lib/ldclient-rb/ldclient.rb +29 -12
- data/lib/ldclient-rb/polling.rb +15 -8
- data/lib/ldclient-rb/requestor.rb +10 -14
- data/lib/ldclient-rb/stream.rb +26 -27
- data/lib/ldclient-rb/user_filter.rb +1 -0
- data/lib/ldclient-rb/util.rb +18 -0
- data/lib/ldclient-rb/version.rb +1 -1
- data/lib/sse_client.rb +4 -0
- data/lib/sse_client/backoff.rb +38 -0
- data/lib/sse_client/sse_client.rb +162 -0
- data/lib/sse_client/sse_events.rb +67 -0
- data/lib/sse_client/streaming_http.rb +195 -0
- data/spec/events_spec.rb +30 -3
- data/spec/ldclient_spec.rb +1 -10
- data/spec/polling_spec.rb +89 -0
- data/spec/sse_client/sse_client_spec.rb +139 -0
- data/spec/sse_client/sse_events_spec.rb +100 -0
- data/spec/sse_client/sse_shared.rb +82 -0
- data/spec/sse_client/streaming_http_spec.rb +263 -0
- metadata +24 -36
data/spec/events_spec.rb
CHANGED
@@ -351,12 +351,12 @@ describe LaunchDarkly::EventProcessor do
|
|
351
351
|
expect(hc.get_request.headers["Authorization"]).to eq "sdk_key"
|
352
352
|
end
|
353
353
|
|
354
|
-
|
354
|
+
def verify_unrecoverable_http_error(status)
|
355
355
|
@ep = subject.new("sdk_key", default_config, hc)
|
356
356
|
e = { kind: "identify", user: user }
|
357
357
|
@ep.add_event(e)
|
358
358
|
|
359
|
-
hc.set_response_status(
|
359
|
+
hc.set_response_status(status)
|
360
360
|
@ep.flush
|
361
361
|
@ep.wait_until_inactive
|
362
362
|
expect(hc.get_request).not_to be_nil
|
@@ -368,7 +368,7 @@ describe LaunchDarkly::EventProcessor do
|
|
368
368
|
expect(hc.get_request).to be_nil
|
369
369
|
end
|
370
370
|
|
371
|
-
|
371
|
+
def verify_recoverable_http_error(status)
|
372
372
|
@ep = subject.new("sdk_key", default_config, hc)
|
373
373
|
e = { kind: "identify", user: user }
|
374
374
|
@ep.add_event(e)
|
@@ -380,6 +380,33 @@ describe LaunchDarkly::EventProcessor do
|
|
380
380
|
expect(hc.get_request).not_to be_nil
|
381
381
|
expect(hc.get_request).not_to be_nil
|
382
382
|
expect(hc.get_request).to be_nil # no 3rd request
|
383
|
+
|
384
|
+
# now verify that a subsequent flush still generates a request
|
385
|
+
hc.reset
|
386
|
+
@ep.add_event(e)
|
387
|
+
@ep.flush
|
388
|
+
@ep.wait_until_inactive
|
389
|
+
expect(hc.get_request).not_to be_nil
|
390
|
+
end
|
391
|
+
|
392
|
+
it "stops posting events after getting a 401 error" do
|
393
|
+
verify_unrecoverable_http_error(401)
|
394
|
+
end
|
395
|
+
|
396
|
+
it "stops posting events after getting a 403 error" do
|
397
|
+
verify_unrecoverable_http_error(403)
|
398
|
+
end
|
399
|
+
|
400
|
+
it "retries after 408 error" do
|
401
|
+
verify_recoverable_http_error(408)
|
402
|
+
end
|
403
|
+
|
404
|
+
it "retries after 429 error" do
|
405
|
+
verify_recoverable_http_error(429)
|
406
|
+
end
|
407
|
+
|
408
|
+
it "retries after 503 error" do
|
409
|
+
verify_recoverable_http_error(503)
|
383
410
|
end
|
384
411
|
|
385
412
|
it "retries flush once after connection error" do
|
data/spec/ldclient_spec.rb
CHANGED
@@ -7,7 +7,7 @@ describe LaunchDarkly::LDClient do
|
|
7
7
|
let(:offline_client) do
|
8
8
|
subject.new("secret", offline_config)
|
9
9
|
end
|
10
|
-
let(:update_processor) { NullUpdateProcessor.new }
|
10
|
+
let(:update_processor) { LaunchDarkly::NullUpdateProcessor.new }
|
11
11
|
let(:config) { LaunchDarkly::Config.new({send_events: false, update_processor: update_processor}) }
|
12
12
|
let(:client) do
|
13
13
|
subject.new("secret", config)
|
@@ -160,13 +160,4 @@ describe LaunchDarkly::LDClient do
|
|
160
160
|
expect(ep).not_to be_a(LaunchDarkly::NullEventProcessor)
|
161
161
|
end
|
162
162
|
end
|
163
|
-
|
164
|
-
class NullUpdateProcessor
|
165
|
-
def start
|
166
|
-
end
|
167
|
-
|
168
|
-
def initialized?
|
169
|
-
true
|
170
|
-
end
|
171
|
-
end
|
172
163
|
end
|
@@ -0,0 +1,89 @@
|
|
1
|
+
require "spec_helper"
|
2
|
+
require 'ostruct'
|
3
|
+
|
4
|
+
describe LaunchDarkly::PollingProcessor do
|
5
|
+
subject { LaunchDarkly::PollingProcessor }
|
6
|
+
let(:store) { LaunchDarkly::InMemoryFeatureStore.new }
|
7
|
+
let(:config) { LaunchDarkly::Config.new(feature_store: store) }
|
8
|
+
let(:requestor) { double() }
|
9
|
+
let(:processor) { subject.new(config, requestor) }
|
10
|
+
|
11
|
+
describe 'successful request' do
|
12
|
+
flag = { key: 'flagkey', version: 1 }
|
13
|
+
segment = { key: 'segkey', version: 1 }
|
14
|
+
all_data = {
|
15
|
+
flags: {
|
16
|
+
flagkey: flag
|
17
|
+
},
|
18
|
+
segments: {
|
19
|
+
segkey: segment
|
20
|
+
}
|
21
|
+
}
|
22
|
+
|
23
|
+
it 'puts feature data in store' do
|
24
|
+
allow(requestor).to receive(:request_all_data).and_return(all_data)
|
25
|
+
ready = processor.start
|
26
|
+
ready.wait
|
27
|
+
expect(store.get(LaunchDarkly::FEATURES, "flagkey")).to eq(flag)
|
28
|
+
expect(store.get(LaunchDarkly::SEGMENTS, "segkey")).to eq(segment)
|
29
|
+
end
|
30
|
+
|
31
|
+
it 'sets initialized to true' do
|
32
|
+
allow(requestor).to receive(:request_all_data).and_return(all_data)
|
33
|
+
ready = processor.start
|
34
|
+
ready.wait
|
35
|
+
expect(processor.initialized?).to be true
|
36
|
+
expect(store.initialized?).to be true
|
37
|
+
end
|
38
|
+
end
|
39
|
+
|
40
|
+
describe 'connection error' do
|
41
|
+
it 'does not cause immediate failure, does not set initialized' do
|
42
|
+
allow(requestor).to receive(:request_all_data).and_raise(StandardError.new("test error"))
|
43
|
+
ready = processor.start
|
44
|
+
finished = ready.wait(0.2)
|
45
|
+
expect(finished).to be false
|
46
|
+
expect(processor.initialized?).to be false
|
47
|
+
expect(store.initialized?).to be false
|
48
|
+
end
|
49
|
+
end
|
50
|
+
|
51
|
+
describe 'HTTP errors' do
|
52
|
+
def verify_unrecoverable_http_error(status)
|
53
|
+
allow(requestor).to receive(:request_all_data).and_raise(LaunchDarkly::UnexpectedResponseError.new(status))
|
54
|
+
ready = processor.start
|
55
|
+
finished = ready.wait(0.2)
|
56
|
+
expect(finished).to be true
|
57
|
+
expect(processor.initialized?).to be false
|
58
|
+
end
|
59
|
+
|
60
|
+
def verify_recoverable_http_error(status)
|
61
|
+
allow(requestor).to receive(:request_all_data).and_raise(LaunchDarkly::UnexpectedResponseError.new(status))
|
62
|
+
ready = processor.start
|
63
|
+
finished = ready.wait(0.2)
|
64
|
+
expect(finished).to be false
|
65
|
+
expect(processor.initialized?).to be false
|
66
|
+
end
|
67
|
+
|
68
|
+
it 'stops immediately for error 401' do
|
69
|
+
verify_unrecoverable_http_error(401)
|
70
|
+
end
|
71
|
+
|
72
|
+
it 'stops immediately for error 403' do
|
73
|
+
verify_unrecoverable_http_error(403)
|
74
|
+
end
|
75
|
+
|
76
|
+
it 'does not stop immediately for error 408' do
|
77
|
+
verify_recoverable_http_error(408)
|
78
|
+
end
|
79
|
+
|
80
|
+
it 'does not stop immediately for error 429' do
|
81
|
+
verify_recoverable_http_error(429)
|
82
|
+
end
|
83
|
+
|
84
|
+
it 'does not stop immediately for error 503' do
|
85
|
+
verify_recoverable_http_error(503)
|
86
|
+
end
|
87
|
+
end
|
88
|
+
end
|
89
|
+
|
@@ -0,0 +1,139 @@
|
|
1
|
+
require "spec_helper"
|
2
|
+
require "socketry"
|
3
|
+
require "sse_client/sse_shared"
|
4
|
+
|
5
|
+
#
|
6
|
+
# End-to-end tests of SSEClient against a real server
|
7
|
+
#
|
8
|
+
describe SSE::SSEClient do
|
9
|
+
subject { SSE::SSEClient }
|
10
|
+
|
11
|
+
def with_client(client)
|
12
|
+
begin
|
13
|
+
yield client
|
14
|
+
ensure
|
15
|
+
client.close
|
16
|
+
end
|
17
|
+
end
|
18
|
+
|
19
|
+
it "sends expected headers" do
|
20
|
+
with_server do |server|
|
21
|
+
requests = Queue.new
|
22
|
+
server.setup_response("/") do |req,res|
|
23
|
+
requests << req
|
24
|
+
res.content_type = "text/event-stream"
|
25
|
+
res.status = 200
|
26
|
+
end
|
27
|
+
|
28
|
+
headers = {
|
29
|
+
"Authorization" => "secret"
|
30
|
+
}
|
31
|
+
|
32
|
+
with_client(subject.new(server.base_uri, headers: headers)) do |client|
|
33
|
+
received_req = requests.pop
|
34
|
+
expect(received_req.header).to eq({
|
35
|
+
"accept" => ["text/event-stream"],
|
36
|
+
"cache-control" => ["no-cache"],
|
37
|
+
"host" => ["127.0.0.1"],
|
38
|
+
"authorization" => ["secret"]
|
39
|
+
})
|
40
|
+
end
|
41
|
+
end
|
42
|
+
end
|
43
|
+
|
44
|
+
it "receives messages" do
|
45
|
+
events_body = <<-EOT
|
46
|
+
event: go
|
47
|
+
data: foo
|
48
|
+
id: 1
|
49
|
+
|
50
|
+
event: stop
|
51
|
+
data: bar
|
52
|
+
|
53
|
+
EOT
|
54
|
+
with_server do |server|
|
55
|
+
server.setup_response("/") do |req,res|
|
56
|
+
res.content_type = "text/event-stream"
|
57
|
+
res.status = 200
|
58
|
+
res.body = events_body
|
59
|
+
end
|
60
|
+
|
61
|
+
event_sink = Queue.new
|
62
|
+
client = subject.new(server.base_uri) do |c|
|
63
|
+
c.on_event { |event| event_sink << event }
|
64
|
+
end
|
65
|
+
|
66
|
+
with_client(client) do |client|
|
67
|
+
expect(event_sink.pop).to eq(SSE::SSEEvent.new(:go, "foo", "1"))
|
68
|
+
expect(event_sink.pop).to eq(SSE::SSEEvent.new(:stop, "bar", nil))
|
69
|
+
end
|
70
|
+
end
|
71
|
+
end
|
72
|
+
|
73
|
+
it "reconnects after error response" do
|
74
|
+
events_body = <<-EOT
|
75
|
+
event: go
|
76
|
+
data: foo
|
77
|
+
|
78
|
+
EOT
|
79
|
+
with_server do |server|
|
80
|
+
attempt = 0
|
81
|
+
server.setup_response("/") do |req,res|
|
82
|
+
attempt += 1
|
83
|
+
if attempt == 1
|
84
|
+
res.status = 500
|
85
|
+
res.body = "sorry"
|
86
|
+
res.keep_alive = false
|
87
|
+
else
|
88
|
+
res.content_type = "text/event-stream"
|
89
|
+
res.status = 200
|
90
|
+
res.body = events_body
|
91
|
+
end
|
92
|
+
end
|
93
|
+
|
94
|
+
event_sink = Queue.new
|
95
|
+
error_sink = Queue.new
|
96
|
+
client = subject.new(server.base_uri, reconnect_time: 0.25) do |c|
|
97
|
+
c.on_event { |event| event_sink << event }
|
98
|
+
c.on_error { |error| error_sink << error }
|
99
|
+
end
|
100
|
+
|
101
|
+
with_client(client) do |client|
|
102
|
+
expect(event_sink.pop).to eq(SSE::SSEEvent.new(:go, "foo", nil))
|
103
|
+
expect(error_sink.pop).to eq({ status_code: 500, body: "sorry" })
|
104
|
+
expect(attempt).to be >= 2
|
105
|
+
end
|
106
|
+
end
|
107
|
+
end
|
108
|
+
|
109
|
+
it "reconnects after read timeout" do
|
110
|
+
events_body = <<-EOT
|
111
|
+
event: go
|
112
|
+
data: foo
|
113
|
+
|
114
|
+
EOT
|
115
|
+
with_server do |server|
|
116
|
+
attempt = 0
|
117
|
+
server.setup_response("/") do |req,res|
|
118
|
+
attempt += 1
|
119
|
+
if attempt == 1
|
120
|
+
sleep(2)
|
121
|
+
end
|
122
|
+
res.content_type = "text/event-stream"
|
123
|
+
res.status = 200
|
124
|
+
res.body = events_body
|
125
|
+
end
|
126
|
+
|
127
|
+
event_sink = Queue.new
|
128
|
+
client = subject.new(server.base_uri,
|
129
|
+
reconnect_time: 0.25, read_timeout: 0.25) do |c|
|
130
|
+
c.on_event { |event| event_sink << event }
|
131
|
+
end
|
132
|
+
|
133
|
+
with_client(client) do |client|
|
134
|
+
expect(event_sink.pop).to eq(SSE::SSEEvent.new(:go, "foo", nil))
|
135
|
+
expect(attempt).to be >= 2
|
136
|
+
end
|
137
|
+
end
|
138
|
+
end
|
139
|
+
end
|
@@ -0,0 +1,100 @@
|
|
1
|
+
require "spec_helper"
|
2
|
+
|
3
|
+
describe SSE::EventParser do
|
4
|
+
subject { SSE::EventParser }
|
5
|
+
|
6
|
+
it "parses an event with all fields" do
|
7
|
+
lines = [
|
8
|
+
"event: abc\r\n",
|
9
|
+
"data: def\r\n",
|
10
|
+
"id: 1\r\n",
|
11
|
+
"\r\n"
|
12
|
+
]
|
13
|
+
ep = subject.new(lines)
|
14
|
+
|
15
|
+
expected_event = SSE::SSEEvent.new(:abc, "def", "1")
|
16
|
+
output = ep.items.to_a
|
17
|
+
expect(output).to eq([ expected_event ])
|
18
|
+
end
|
19
|
+
|
20
|
+
it "parses an event with only data" do
|
21
|
+
lines = [
|
22
|
+
"data: def\r\n",
|
23
|
+
"\r\n"
|
24
|
+
]
|
25
|
+
ep = subject.new(lines)
|
26
|
+
|
27
|
+
expected_event = SSE::SSEEvent.new(:message, "def", nil)
|
28
|
+
output = ep.items.to_a
|
29
|
+
expect(output).to eq([ expected_event ])
|
30
|
+
end
|
31
|
+
|
32
|
+
it "parses an event with multi-line data" do
|
33
|
+
lines = [
|
34
|
+
"data: def\r\n",
|
35
|
+
"data: ghi\r\n",
|
36
|
+
"\r\n"
|
37
|
+
]
|
38
|
+
ep = subject.new(lines)
|
39
|
+
|
40
|
+
expected_event = SSE::SSEEvent.new(:message, "def\nghi", nil)
|
41
|
+
output = ep.items.to_a
|
42
|
+
expect(output).to eq([ expected_event ])
|
43
|
+
end
|
44
|
+
|
45
|
+
it "ignores comments" do
|
46
|
+
lines = [
|
47
|
+
":",
|
48
|
+
"data: def\r\n",
|
49
|
+
":",
|
50
|
+
"\r\n"
|
51
|
+
]
|
52
|
+
ep = subject.new(lines)
|
53
|
+
|
54
|
+
expected_event = SSE::SSEEvent.new(:message, "def", nil)
|
55
|
+
output = ep.items.to_a
|
56
|
+
expect(output).to eq([ expected_event ])
|
57
|
+
end
|
58
|
+
|
59
|
+
it "parses reconnect interval" do
|
60
|
+
lines = [
|
61
|
+
"retry: 2500\r\n",
|
62
|
+
"\r\n"
|
63
|
+
]
|
64
|
+
ep = subject.new(lines)
|
65
|
+
|
66
|
+
expected_item = SSE::SSESetRetryInterval.new(2500)
|
67
|
+
output = ep.items.to_a
|
68
|
+
expect(output).to eq([ expected_item ])
|
69
|
+
end
|
70
|
+
|
71
|
+
it "parses multiple events" do
|
72
|
+
lines = [
|
73
|
+
"event: abc\r\n",
|
74
|
+
"data: def\r\n",
|
75
|
+
"id: 1\r\n",
|
76
|
+
"\r\n",
|
77
|
+
"data: ghi\r\n",
|
78
|
+
"\r\n"
|
79
|
+
]
|
80
|
+
ep = subject.new(lines)
|
81
|
+
|
82
|
+
expected_event_1 = SSE::SSEEvent.new(:abc, "def", "1")
|
83
|
+
expected_event_2 = SSE::SSEEvent.new(:message, "ghi", nil)
|
84
|
+
output = ep.items.to_a
|
85
|
+
expect(output).to eq([ expected_event_1, expected_event_2 ])
|
86
|
+
end
|
87
|
+
|
88
|
+
it "ignores events with no data" do
|
89
|
+
lines = [
|
90
|
+
"event: nothing\r\n",
|
91
|
+
"\r\n",
|
92
|
+
"event: nada\r\n",
|
93
|
+
"\r\n"
|
94
|
+
]
|
95
|
+
ep = subject.new(lines)
|
96
|
+
|
97
|
+
output = ep.items.to_a
|
98
|
+
expect(output).to eq([])
|
99
|
+
end
|
100
|
+
end
|
@@ -0,0 +1,82 @@
|
|
1
|
+
require "spec_helper"
|
2
|
+
require "webrick"
|
3
|
+
require "webrick/httpproxy"
|
4
|
+
require "webrick/https"
|
5
|
+
|
6
|
+
class StubHTTPServer
|
7
|
+
def initialize
|
8
|
+
@port = 50000
|
9
|
+
begin
|
10
|
+
@server = create_server(@port)
|
11
|
+
rescue Errno::EADDRINUSE
|
12
|
+
@port += 1
|
13
|
+
retry
|
14
|
+
end
|
15
|
+
end
|
16
|
+
|
17
|
+
def create_server(port)
|
18
|
+
WEBrick::HTTPServer.new(
|
19
|
+
BindAddress: '127.0.0.1',
|
20
|
+
Port: port,
|
21
|
+
AccessLog: [],
|
22
|
+
Logger: NullLogger.new
|
23
|
+
)
|
24
|
+
end
|
25
|
+
|
26
|
+
def start
|
27
|
+
Thread.new { @server.start }
|
28
|
+
end
|
29
|
+
|
30
|
+
def stop
|
31
|
+
@server.shutdown
|
32
|
+
end
|
33
|
+
|
34
|
+
def base_uri
|
35
|
+
URI("http://127.0.0.1:#{@port}")
|
36
|
+
end
|
37
|
+
|
38
|
+
def setup_response(uri_path, &action)
|
39
|
+
@server.mount_proc(uri_path, action)
|
40
|
+
end
|
41
|
+
end
|
42
|
+
|
43
|
+
class StubProxyServer < StubHTTPServer
|
44
|
+
attr_reader :request_count
|
45
|
+
attr_accessor :connect_status
|
46
|
+
|
47
|
+
def initialize
|
48
|
+
super
|
49
|
+
@request_count = 0
|
50
|
+
end
|
51
|
+
|
52
|
+
def create_server(port)
|
53
|
+
WEBrick::HTTPProxyServer.new(
|
54
|
+
BindAddress: '127.0.0.1',
|
55
|
+
Port: port,
|
56
|
+
AccessLog: [],
|
57
|
+
Logger: NullLogger.new,
|
58
|
+
ProxyContentHandler: proc do |req,res|
|
59
|
+
if !@connect_status.nil?
|
60
|
+
res.status = @connect_status
|
61
|
+
end
|
62
|
+
@request_count += 1
|
63
|
+
end
|
64
|
+
)
|
65
|
+
end
|
66
|
+
end
|
67
|
+
|
68
|
+
class NullLogger
|
69
|
+
def method_missing(*)
|
70
|
+
self
|
71
|
+
end
|
72
|
+
end
|
73
|
+
|
74
|
+
def with_server(server = nil)
|
75
|
+
server = StubHTTPServer.new if server.nil?
|
76
|
+
begin
|
77
|
+
server.start
|
78
|
+
yield server
|
79
|
+
ensure
|
80
|
+
server.stop
|
81
|
+
end
|
82
|
+
end
|