spec/acceptance/realtime/connection_failures_spec.rb in ably-0.8.5 vs spec/acceptance/realtime/connection_failures_spec.rb in ably-0.8.6

- old
+ new

@@ -51,23 +51,21 @@ end end end context 'automatic connection retry' do - let(:client_failure_options) { default_options.merge(log_level: :none) } - context 'with invalid WebSocket host' do let(:retry_every_for_tests) { 0.2 } let(:max_time_in_state_for_tests) { 0.6 } - before do - # Reconfigure client library retry periods and timeouts so that tests run quickly - stub_const 'Ably::Realtime::Connection::ConnectionManager::CONNECT_RETRY_CONFIG', - Ably::Realtime::Connection::ConnectionManager::CONNECT_RETRY_CONFIG.merge( - disconnected: { retry_every: retry_every_for_tests, max_time_in_state: max_time_in_state_for_tests }, - suspended: { retry_every: retry_every_for_tests, max_time_in_state: max_time_in_state_for_tests }, - ) + let(:client_failure_options) do + default_options.merge( + log_level: :none, + disconnected_retry_timeout: retry_every_for_tests, + suspended_retry_timeout: retry_every_for_tests, + connection_state_ttl: max_time_in_state_for_tests + ) end # retry immediately after failure, then one retry every :retry_every_for_tests let(:expected_retry_attempts) { 1 + (max_time_in_state_for_tests / retry_every_for_tests).round } let(:state_changes) { Hash.new { |hash, key| hash[key] = 0 } } @@ -108,10 +106,31 @@ expect(time_passed).to be > max_time_in_state_for_tests stop_reactor end end + context 'for the first time' do + let(:client_options) do + default_options.merge(realtime_host: 'non.existent.host', disconnected_retry_timeout: 2, log_level: :error) + end + + it 'reattempts connection immediately and then waits disconnected_retry_timeout for a subsequent attempt' do + expect(connection.defaults[:disconnected_retry_timeout]).to eql(2) + connection.once(:disconnected) do + started_at = Time.now.to_f + connection.once(:disconnected) do + expect(Time.now.to_f - started_at).to be < 1 + started_at = Time.now.to_f + connection.once(:disconnected) do + expect(Time.now.to_f - started_at).to be > 2 + stop_reactor + end + end + end + end + end + describe '#close' do it 'transitions connection state to :closed' do connection.on(:connected) { raise 'Connection should not have reached :connected state' } connection.on(:failed) { raise 'Connection should not have reached :failed state yet' } @@ -128,29 +147,54 @@ end end end context 'when connection state is :suspended' do - it 'enters the failed state after multiple attempts if the max_time_in_state is set' do + it 'stays in the suspended state after any number of reconnection attempts' do connection.on(:connected) { raise 'Connection should not have reached :connected state' } connection.once(:suspended) do count_state_changes && start_timer - connection.on(:failed) do - expect(connection.state).to eq(:failed) + EventMachine.add_timer((retry_every_for_tests + 0.1) * 10) do + expect(connection.state).to eq(:suspended) - expect(state_changes[:connecting]).to eql(expected_retry_attempts) - expect(state_changes[:suspended]).to eql(expected_retry_attempts) + expect(state_changes[:connecting]).to be >= 10 + expect(state_changes[:suspended]).to be >= 10 expect(state_changes[:disconnected]).to eql(0) - expect(time_passed).to be > max_time_in_state_for_tests stop_reactor end end end + context 'for the first time' do + let(:client_options) do + default_options.merge(suspended_retry_timeout: 2, connection_state_ttl: 0, log_level: :error) + end + + it 'waits suspended_retry_timeout before attempting to reconnect' do + expect(client.connection.defaults[:suspended_retry_timeout]).to eql(2) + connection.once(:connected) do + connection.transition_state_machine :suspended + allow(connection).to receive(:current_host).and_return('does.not.exist.com') + + started_at = Time.now.to_f + connection.once(:connecting) do + expect(Time.now.to_f - started_at).to be > 1.75 + started_at = Time.now.to_f + connection.once(:connecting) do + expect(Time.now.to_f - started_at).to be > 1.75 + connection.once(:suspended) do + stop_reactor + end + end + end + end + end + end + describe '#close' do it 'transitions connection state to :closed' do connection.on(:connected) { raise 'Connection should not have reached :connected state' } connection.once(:suspended) do @@ -170,10 +214,14 @@ context 'when connection state is :failed' do describe '#close' do it 'will not transition state to :close and raises a InvalidStateChange exception' do connection.on(:connected) { raise 'Connection should not have reached :connected state' } + connection.once(:suspended) do + connection.transition_state_machine :failed + end + connection.once(:failed) do expect(connection.state).to eq(:failed) expect { connection.close }.to raise_error Ably::Exceptions::InvalidStateChange, /Unable to transition from failed => closing/ stop_reactor end @@ -188,10 +236,14 @@ error = connection_state_change.reason expect(connection.error_reason).to eq(error) expect(connection.error_reason.code).to eql(80000) stop_reactor end + + connection.once(:suspended) do |connection_state_change| + connection.transition_state_machine :failed, reason: connection_state_change.reason + end end end it 'is reset to nil when :connected' do connection.once(:disconnected) do |error| @@ -214,64 +266,62 @@ end end end describe '#connect' do - let(:timeouts) { Ably::Realtime::Connection::ConnectionManager::TIMEOUTS } + let(:timeout) { 1.5 } - before do - stub_const 'Ably::Realtime::Connection::ConnectionManager::TIMEOUTS', - Ably::Realtime::Connection::ConnectionManager::TIMEOUTS.merge(open: 1.5) + let(:client_options) do + default_options.merge( + log_level: :none, + realtime_request_timeout: timeout + ) + end + before do connection.on(:connected) { raise "Connection should not open in this test as CONNECTED ProtocolMessage is never received" } connection.once(:connecting) do # don't process any incoming ProtocolMessages so the connection never opens connection.__incoming_protocol_msgbus__.unsubscribe end end context 'connection opening times out' do - let(:client_options) { client_failure_options } - it 'attempts to reconnect' do started_at = Time.now connection.once(:disconnected) do - expect(Time.now.to_f - started_at.to_f).to be > timeouts.fetch(:open) + expect(Time.now.to_f - started_at.to_f).to be > timeout connection.once(:connecting) do stop_reactor end end connection.connect end - it 'calls the errback of the returned Deferrable object when first connection attempt fails' do - connection.connect.errback do |error| - expect(connection.state).to eq(:disconnected) - stop_reactor - end - end - context 'when retry intervals are stubbed to attempt reconnection quickly' do - before do - # Reconfigure client library retry periods and timeouts so that tests run quickly - stub_const 'Ably::Realtime::Connection::ConnectionManager::CONNECT_RETRY_CONFIG', - Ably::Realtime::Connection::ConnectionManager::CONNECT_RETRY_CONFIG.merge( - disconnected: { retry_every: 0.1, max_time_in_state: 0.2 }, - suspended: { retry_every: 0.1, max_time_in_state: 0.2 }, - ) + let(:client_options) do + default_options.merge( + log_level: :error, + disconnected_retry_timeout: 0.1, + suspended_retry_timeout: 0.1, + connection_state_ttl: 0.2, + realtime_host: 'non.existent.host' + ) end it 'never calls the provided success block', em_timeout: 10 do connection.connect do raise 'success block should not have been called' end - connection.once(:failed) do - stop_reactor + connection.once(:suspended) do + connection.once(:suspended) do + stop_reactor + end end end end end end @@ -313,18 +363,20 @@ end context 'and subsequently fails to reconnect' do let(:retry_every) { 1.5 } - before do - # Reconfigure client library retry periods and timeouts so that tests run quickly - stub_const 'Ably::Realtime::Connection::ConnectionManager::CONNECT_RETRY_CONFIG', - Ably::Realtime::Connection::ConnectionManager::CONNECT_RETRY_CONFIG.merge( - disconnected: { retry_every: retry_every, max_time_in_state: 60 }) + let(:client_options) do + default_options.merge( + log_level: :none, + disconnected_retry_timeout: retry_every, + suspended_retry_timeout: retry_every, + connection_state_ttl: 60 + ) end - it "retries every #{Ably::Realtime::Connection::ConnectionManager::CONNECT_RETRY_CONFIG[:disconnected][:retry_every]} seconds" do + it "retries every #{Ably::Realtime::Connection::DEFAULTS.fetch(:disconnected_retry_timeout)} seconds" do fail_if_suspended_or_failed stubbed_first_attempt = false connection.once(:connected) do @@ -380,30 +432,56 @@ end end end context 'after successfully reconnecting and resuming' do - it 'retains connection_id and connection_key' do - previous_connection_id = nil - previous_connection_key = nil - + it 'retains connection_id and updates the connection_key' do connection.once(:connected) do - previous_connection_id = connection.id - previous_connection_key = connection.key + previous_connection_id = connection.id connection.transport.close_connection_after_writing + expect(connection).to receive(:configure_new).with(previous_connection_id, anything, anything).and_call_original + connection.once(:connected) do - # Connection key left part should match new connection key left part i.e. - # wVIsgTHAB1UvXh7z-1991d8586 becomes wVIsgTHAB1UvXh7z-1990d8586 after resume - expect(connection.key[/^\w{5,}-/, 0]).to_not be_nil - expect(connection.key[/^\w{5,}-/, 0]).to eql(previous_connection_key[/^\w{5,}-/, 0]) + expect(connection.key).to_not be_nil expect(connection.id).to eql(previous_connection_id) stop_reactor end end end + it 'emits any error received from Ably but leaves the channels attached' do + emitted_error = nil + channel.attach do + connection.transport.close_connection_after_writing + + connection.once(:connecting) do + connection.__incoming_protocol_msgbus__.unsubscribe + connection.__incoming_protocol_msgbus__.subscribe(:protocol_message) do |protocol_message| + allow(protocol_message).to receive(:error).and_return(Ably::Exceptions::Standard.new('Injected error')) + end + # Create a new message dispatcher that subscribes to ProtocolMessages after the previous subscription allowing us + # to modify the ProtocolMessage + Ably::Realtime::Client::IncomingMessageDispatcher.new(client, connection) + end + + connection.once(:connected) do + EM.add_timer(0.5) do + expect(emitted_error).to be_a(Ably::Exceptions::Standard) + expect(emitted_error.message).to match(/Injected error/) + expect(connection.error_reason).to be_a(Ably::Exceptions::Standard) + expect(channel).to be_attached + stop_reactor + end + end + + connection.once(:error) do |error| + emitted_error = error + end + end + end + it 'retains channel subscription state' do messages_received = false channel.subscribe('event') do |message| expect(message.data).to eql('message') @@ -525,73 +603,80 @@ end end end describe 'fallback host feature' do - let(:retry_every_for_tests) { 0.1 } - let(:max_time_in_state_for_tests) { 0.3 } + let(:retry_every_for_tests) { 0.2 } + let(:max_time_in_state_for_tests) { 0.59 } - before do - # Reconfigure client library retry periods and timeouts so that tests run quickly - stub_const 'Ably::Realtime::Connection::ConnectionManager::CONNECT_RETRY_CONFIG', - Ably::Realtime::Connection::ConnectionManager::CONNECT_RETRY_CONFIG.merge( - disconnected: { retry_every: retry_every_for_tests, max_time_in_state: max_time_in_state_for_tests }, - suspended: { retry_every: retry_every_for_tests, max_time_in_state: max_time_in_state_for_tests }, - ) + let(:timeout_options) do + default_options.merge( + environment: :production, + log_level: :none, + disconnected_retry_timeout: retry_every_for_tests, + suspended_retry_timeout: retry_every_for_tests, + connection_state_ttl: max_time_in_state_for_tests + ) end # Retry immediately and then wait retry_every before every subsequent attempt - let(:expected_retry_attempts) { 1 + (max_time_in_state_for_tests / retry_every_for_tests).round } + let(:expected_retry_attempts) { 1 + (max_time_in_state_for_tests / retry_every_for_tests).round } let(:retry_count_for_one_state) { 1 + expected_retry_attempts } # initial connect then disconnected - let(:retry_count_for_all_states) { 1 + expected_retry_attempts * 2 } # initial connection, disconnected & then suspended + let(:retry_count_for_all_states) { 1 + expected_retry_attempts + 1 } # initial connection, disconnected & then one suspended attempt context 'with custom realtime websocket host option' do let(:expected_host) { 'this.host.does.not.exist' } - let(:client_options) { default_options.merge(realtime_host: expected_host, :environment => :production, log_level: :none) } + let(:client_options) { timeout_options.merge(realtime_host: expected_host) } it 'never uses a fallback host' do expect(EventMachine).to receive(:connect).exactly(retry_count_for_all_states).times do |host| expect(host).to eql(expected_host) raise EventMachine::ConnectionError end - connection.on(:failed) do - stop_reactor + connection.once(:suspended) do + connection.once(:suspended) do + stop_reactor + end end end end context 'with custom realtime websocket port option' do let(:custom_port) { 666} - let(:client_options) { default_options.merge(tls_port: custom_port, :environment => :production, log_level: :none) } + let(:client_options) { timeout_options.merge(tls_port: custom_port) } it 'never uses a fallback host' do expect(EventMachine).to receive(:connect).exactly(retry_count_for_all_states).times do |host, port| expect(port).to eql(custom_port) raise EventMachine::ConnectionError end - connection.on(:failed) do - stop_reactor + connection.once(:suspended) do + connection.once(:suspended) do + stop_reactor + end end end end context 'with non-production environment' do let(:environment) { 'sandbox' } let(:expected_host) { "#{environment}-#{Ably::Realtime::Client::DOMAIN}" } - let(:client_options) { default_options.merge(environment: environment, log_level: :none) } + let(:client_options) { timeout_options.merge(environment: environment) } it 'never uses a fallback host' do expect(EventMachine).to receive(:connect).exactly(retry_count_for_all_states).times do |host| expect(host).to eql(expected_host) raise EventMachine::ConnectionError end - connection.on(:failed) do - stop_reactor + connection.once(:suspended) do + connection.once(:suspended) do + stop_reactor + end end end end context 'with production environment' do @@ -599,11 +684,11 @@ before do stub_const 'Ably::FALLBACK_HOSTS', custom_hosts end let(:expected_host) { Ably::Realtime::Client::DOMAIN } - let(:client_options) { default_options.merge(environment: nil, log_level: :none) } + let(:client_options) { timeout_options.merge(environment: nil) } let(:fallback_hosts_used) { Array.new } context 'when the Internet is down' do before do @@ -614,12 +699,14 @@ expect(EventMachine).to receive(:connect).exactly(retry_count_for_all_states).times do |host| expect(host).to eql(expected_host) raise EventMachine::ConnectionError end - connection.on(:failed) do - stop_reactor + connection.once(:suspended) do + connection.once(:suspended) do + stop_reactor + end end end end context 'when the Internet is up' do @@ -627,43 +714,47 @@ allow(connection).to receive(:internet_up?).and_yield(true) end it 'uses a fallback host on every subsequent disconnected attempt until suspended' do request = 0 - # Expect retry attempts + 1 attempt for the next state - expect(EventMachine).to receive(:connect).exactly(retry_count_for_one_state + 1).times do |host| + expect(EventMachine).to receive(:connect).exactly(retry_count_for_one_state).times do |host| if request == 0 expect(host).to eql(expected_host) else fallback_hosts_used << host end request += 1 raise EventMachine::ConnectionError end - connection.on(:suspended) do + connection.once(:suspended) do fallback_hosts_used.pop # remove suspended attempt host expect(fallback_hosts_used.uniq).to match_array(custom_hosts) stop_reactor end end it 'uses the primary host when suspended, and a fallback host on every subsequent suspended attempt' do request = 0 - expect(EventMachine).to receive(:connect).exactly(retry_count_for_all_states).times do |host| + expect(EventMachine).to receive(:connect).at_least(:once) do |host| if request == 0 || request == expected_retry_attempts + 1 expect(host).to eql(expected_host) else expect(custom_hosts).to include(host) - fallback_hosts_used << host + fallback_hosts_used << host if @suspended end request += 1 raise EventMachine::ConnectionError end - connection.on(:failed) do - expect(fallback_hosts_used.uniq).to match_array(custom_hosts) - stop_reactor + connection.on(:suspended) do + @suspended ||= 0 + @suspended += 1 + + if @suspended > 3 + expect(fallback_hosts_used.uniq).to match_array(custom_hosts) + stop_reactor + end end end end end end