Permalink
Browse files

sdk/ruby: refresh session based on TTL

If we ever change the hostname for a ledger,
we don't want client software to need to be restarted
to pick up the new value.

The `/hello` endpoint returns a TTL for this purpose.
When the TTL expires, re-build the HTTP client
used for all API requests in the SDK.

If there is a problem with subsequent `/hello` requests,
it is okay for the SDK to continue using the address it already knows
while it continues trying for a successful `/hello`.
However, if the SDK has a problem making its initial `/hello` request,
it should report an error to the application.

`Session` is the only object that uses `team_name` and `addr`.
Move `/hello` into `Session` to manage this state.

Running hello in a background thread means it runs
concurrently with the application's thread(s), and it needs
to update some instance variables.

Instance variables are not safe for concurrent access,
so we must synchronize all reads and writes to shared
instance variables with a mutex.

If we fail to do this, things will keep working right up
until they break, and then it will be very hard to debug.

Extract a `Hello` object for testability.

Closes #4069

Author: Dan Croak <dan@statusok.com>
Date: Wed Jul 11 15:56:47 2018 -0700
upstream:fde0eaa191fe25fa9ffce17f6e04e3eddc2b3cec
  • Loading branch information...
croaky authored and i10rbot committed Jul 11, 2018
1 parent 3930507 commit d00d15292808b7cf3c69c879ae9da781c819e658
Showing with 141 additions and 26 deletions.
  1. +2 −14 lib/sequence/client.rb
  2. +15 −0 lib/sequence/hello.rb
  3. +58 −9 lib/sequence/session.rb
  4. +0 −2 spec/unit/client_spec.rb
  5. +66 −1 spec/unit/session_spec.rb
@@ -28,15 +28,11 @@ def initialize(ledger_name:, credential:)
raise ArgumentError, ':credential cannot be blank'
end
addr = ENV['SEQADDR'] || 'api.seq.com'
api = HttpWrapper.new('https://' + addr, credential)
hello_resp = hello(api)
@opts = {
addr: hello_resp['addr'],
credential: credential,
ledger_name: ledger_name,
team_name: hello_resp['team_name'],
}
@session = Session.new(@opts)
end
# @private
@@ -46,9 +42,7 @@ def opts
# @private
# @return [Session]
def session
@session ||= Session.new(@opts)
end
attr_reader :session
# @return [Account::ClientModule]
def accounts
@@ -95,11 +89,5 @@ def stats
def dev_utils
@dev_utils ||= DevUtils::ClientModule.new(self)
end
private
def hello(api)
api.post(SecureRandom.hex(10), '/hello', {})[:parsed_body]
end
end
end
@@ -0,0 +1,15 @@
# frozen_string_literal: true
module Sequence
# @private
class Hello
def initialize(api)
@api = api
end
def call
b = @api.post(SecureRandom.hex(10), '/hello', {})[:parsed_body]
[b['team_name'], b['addr'], b['addr_ttl_seconds'].to_i]
end
end
end
@@ -5,6 +5,7 @@
require_relative './http_wrapper'
require_relative './errors'
require_relative './version'
require_relative './hello'
module Sequence
# @private
@@ -19,11 +20,11 @@ def initialize(opts)
ArgumentError,
'missing credential',
)
@team_name = @opts[:team_name] || raise(
ArgumentError,
'missing team_name',
)
@ledger_api = HttpWrapper.new('https://' + @opts[:addr], @credential, @opts)
@lock = Mutex.new # protects the following instance variables
@team_name, @addr, ttl_seconds = hello.call
@api = api(@addr)
@deadline = now + ttl_seconds
end
def dup
@@ -36,7 +37,20 @@ def request(path, body = {})
def request_full_resp(id, path, body = {})
id ||= SecureRandom.hex(10)
@ledger_api.post(id, ledger_url(path), body) do |response|
deadline = nil
api = nil
@lock.synchronize do
deadline = @deadline
api = @api
path = "/#{@team_name}/#{@ledger}/#{path}".gsub('//', '/')
end
if now >= deadline
refresh
end
api.post(id, path, body) do |response|
# require that the response contains the Chain-Request-ID
# http header. Since the Sequence API will always set this
# header, its absence indicates that the request stopped at
@@ -52,9 +66,44 @@ def request_full_resp(id, path, body = {})
private
def ledger_url(path)
path = path[1..-1] if path.start_with?('/')
"/#{@team_name}/#{@ledger}/#{path}"
def refresh
Thread.new do
# extend the deadline long enough to get a fresh addr
@lock.synchronize do
@deadline = now + HttpWrapper::RETRY_TIMEOUT_SECS
end
begin
new_team_name, new_addr, ttl_seconds = hello.call
rescue StandardError # rubocop:disable Lint/HandleExceptions
# use existing values while trying for a successful /hello
else
@lock.synchronize do
@deadline = now + ttl_seconds
# unless addr changed, use existing API client
# in order to re-use the TLS connection
if @addr != new_addr
@addr = new_addr
@api = api(new_addr)
end
@team_name = new_team_name
end
end
end
end
def now
Process.clock_gettime(Process::CLOCK_MONOTONIC).to_i
end
def hello
Sequence::Hello.new(api(ENV['SEQADDR'] || 'api.seq.com'))
end
def api(addr)
HttpWrapper.new('https://' + addr, @credential, @opts)
end
end
end
@@ -14,10 +14,8 @@
ledger_name: ledger_name,
)
expect(ledger.opts[:addr]).to eq('chain.localhost:1999')
expect(ledger.opts[:credential]).to eq(credential)
expect(ledger.opts[:ledger_name]).to eq(ledger_name)
expect(ledger.opts[:team_name]).to eq('team')
end
end
@@ -1,8 +1,21 @@
# frozen_string_literal: true
describe Sequence::Session do
describe '#new' do
context 'with valid options' do
it 'instantiates the session' do
session = chain.session
expect(session.instance_variable_get(:@team_name)).to eq('team')
api = session.instance_variable_get(:@api)
expect(api.instance_variable_get(:@base_url).to_s)
.to eq('https://chain.localhost:1999')
end
end
end
describe '#request' do
it 'makes requests against ledger API using refresh method' do
it 'makes requests against ledger API' do
chain.dev_utils.reset
result = chain.session.request('/stats')
@@ -11,5 +24,57 @@
expect(result['account_count']).to eq(0)
expect(result['tx_count']).to eq(0)
end
context 'when addr changes between refreshes' do
it 'uses the new addr on subsequent requests' do
ttl_seconds = 0
hello = instance_double('Sequence::Hello')
allow(Sequence::Hello).to receive(:new).and_return(hello)
allow(hello).to receive(:call)
.and_return(['team', 'chain.localhost:1999', ttl_seconds])
session = Sequence::Session.new(
credential: ENV['SEQCRED'],
ledger_name: ENV.fetch('LEDGER_NAME', 'test'),
)
api = session.instance_variable_get(:@api)
expect(api.instance_variable_get(:@base_url).to_s)
.to eq('https://chain.localhost:1999')
allow(hello).to receive(:call)
.and_return(['team', 'changed.localhost:1999', ttl_seconds])
session.request('/stats') # deadline of 0 reached, refresh in thread
sleep 1 # wait for thread to finish
api = session.instance_variable_get(:@api)
expect(api.instance_variable_get(:@base_url).to_s)
.to eq('https://changed.localhost:1999')
end
end
end
context 'when hello errors during a refresh' do
it 'continues using the previous addr' do
ttl_seconds = 0
hello = instance_double('Sequence::Hello')
allow(Sequence::Hello).to receive(:new).and_return(hello)
allow(hello).to receive(:call)
.and_return(['team', 'chain.localhost:1999', ttl_seconds])
session = Sequence::Session.new(
credential: ENV['SEQCRED'],
ledger_name: ENV.fetch('LEDGER_NAME', 'test'),
)
api = session.instance_variable_get(:@api)
expect(api.instance_variable_get(:@base_url).to_s)
.to eq('https://chain.localhost:1999')
allow(hello).to receive(:call).and_raise('error')
api = session.instance_variable_get(:@api)
expect(api.instance_variable_get(:@base_url).to_s)
.to eq('https://chain.localhost:1999')
end
end
end

0 comments on commit d00d152

Please sign in to comment.