mamantoha / crest

HTTP and REST client for Crystal
https://mamantoha.github.io/crest/
MIT License
235 stars 14 forks source link

Add built in connection pooling #138

Closed watzon closed 4 years ago

watzon commented 4 years ago

It would be nice, for my use case at least, if crest had the ability to maintain a pool of internal clients, and gave the user the ability to decide how large that pool was. Something like ysbaddaden/pool could be used underneath.

mamantoha commented 4 years ago

@watzon I'm just curious about what kind of problem you try to solve with a pool connection?

watzon commented 4 years ago

As things sit, HTTP clients cannot be used across fibers/threads. If you try you end up getting a massive stack trace that points to an OpenSSL problem, when in reality it's just that HTTP::Client itself isn't thread safe. To get around that you can use a connection pool so that each fiber just pulls a client out of the pool, uses it, and checks it back in.

Not great for memory I have to say, but it works.

mamantoha commented 4 years ago

Interesting. I'll try to implement this.

mamantoha commented 4 years ago

Probably I will not implement this feature for crest.

But you can easily wrap HTTP::Client with a connection pool. Something like this:

class HTTP::PooledClient
  getter pool

  def initialize(*args, pool_size = 5, pool_timeout = 10.0, **args2)
    @pool = ConnectionPool(HTTP::Client).new(capacity: pool_size, timeout: pool_timeout) do
      HTTP::Client.new(*args, **args2)
    end
  end

  macro method_missing(call)
    # Delegates all methods to a `HTTP::Client` instance from the connection pool.
    with_pool_connection { |conn| conn.{{call}} }
  end

  private def with_pool_connection
    conn = begin
      @pool.checkout
    rescue IO::TimeoutError
      raise Exception.new("No free connection (used #{@pool.size} of #{@pool.capacity}) after timeout of #{@pool.timeout}s")
    end

    begin
      yield(conn)
    ensure
      @pool.checkin(conn)
    end
  end
end

I checked with code and looks like it works as you want:

url = "https://httpbin.org"
client = HTTP::PooledClient.new(URI.parse(url))

15.times do |i|
  spawn do
    url = "/delay/#{rand(1..5)}"
    response = client.get(url)
    print "[#{i}] "
    puts JSON.parse(response.body)["url"]
  end
end

Fiber.yield

gets

Result

[3] https://httpbin.org/delay/1
[1] https://httpbin.org/delay/2
[4] https://httpbin.org/delay/3
[6] https://httpbin.org/delay/1
[5] https://httpbin.org/delay/3
[0] https://httpbin.org/delay/5
[2] https://httpbin.org/delay/5
[7] https://httpbin.org/delay/2
[9] https://httpbin.org/delay/1
[8] https://httpbin.org/delay/2
[10] https://httpbin.org/delay/1
[11] https://httpbin.org/delay/3
[12] https://httpbin.org/delay/3
[13] https://httpbin.org/delay/5
[14] https://httpbin.org/delay/4
watzon commented 4 years ago

Understandable, thanks :+1: