Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .rubocop/style.yml
Original file line number Diff line number Diff line change
Expand Up @@ -36,9 +36,11 @@ Style/OptionHash:
Enabled: true
Exclude:
- 'lib/http/chainable.rb'
- 'lib/http/chainable/verbs.rb'
- 'lib/http/client.rb'
- 'lib/http/options.rb'
- 'lib/http/redirector.rb'
- 'lib/http/session.rb'
- 'lib/http/timeout/null.rb'
- 'test/support/dummy_server.rb'

Expand Down
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Changed

- **BREAKING** Chainable option methods (`.headers`, `.timeout`, `.cookies`,
`.auth`, `.follow`, `.via`, `.use`, `.encoding`, `.nodelay`, `.basic_auth`,
`.accept`) now return a thread-safe `HTTP::Session` instead of `HTTP::Client`.
`Session` creates a new `Client` for each request, making it safe to share a
configured session across threads. `HTTP.persistent` still returns
`HTTP::Client` since persistent connections require mutable state. Code that
calls HTTP verb methods (`.get`, `.post`, etc.) or accesses `.default_options`
is unaffected. Code that checks `is_a?(HTTP::Client)` on the return value of
chainable methods will need to be updated to check for `HTTP::Session`
- **BREAKING** `.retriable` now returns `HTTP::Session` instead of
`HTTP::Retriable::Client`. Retry is a session-level option: it flows through
`HTTP::Options` into `HTTP::Client#perform`, eliminating the need for
separate `Retriable::Client` and `Retriable::Session` classes
- Improved error message when request body size cannot be determined to suggest
setting `Content-Length` explicitly or using chunked `Transfer-Encoding` (#560)
- **BREAKING** `Connection#readpartial` now raises `EOFError` instead of
Expand Down
28 changes: 28 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,34 @@ end
Pattern matching is also supported on `HTTP::Response::Status`, `HTTP::Headers`,
`HTTP::ContentType`, and `HTTP::URI`.

### Thread Safety

Configured sessions are safe to share across threads:

```ruby
# Build a session once, use it from any thread
session = HTTP.headers("Accept" => "application/json")
.timeout(10)
.auth("Bearer token")

threads = 10.times.map do
Thread.new { session.get("https://example.com/api/data") }
end
threads.each(&:join)
```

Chainable configuration methods (`.headers`, `.timeout`, `.auth`, etc.) return
an `HTTP::Session`, which creates a fresh `HTTP::Client` for every request.

Persistent connections (`HTTP.persistent`) return an `HTTP::Client`, which is
**not** thread-safe. For thread-safe persistent connections, use the
[connection_pool](https://rubygems.org/gems/connection_pool) gem:

```ruby
pool = ConnectionPool.new(size: 5) { HTTP.persistent("https://example.com") }
pool.with { |http| http.get("/path") }
```

## Supported Ruby Versions

This library aims to support and is [tested against][build-link]
Expand Down
6 changes: 3 additions & 3 deletions lib/http.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@
require "http/timeout/per_operation"
require "http/timeout/global"
require "http/chainable"
require "http/session"
require "http/client"
require "http/retriable/client"
require "http/connection"
require "http/options"
require "http/feature"
Expand All @@ -21,14 +21,14 @@ module HTTP
extend Chainable

class << self
# Set default headers and return a chainable client instance
# Set default headers and return a chainable session
#
# @example
# HTTP[:accept => "text/html"].get("https://example.com")
#
# @param headers [Hash] headers to set
#
# @return [HTTP::Client]
# @return [HTTP::Session]
#
# @api public
alias [] headers
Expand Down
167 changes: 31 additions & 136 deletions lib/http/chainable.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,129 +2,14 @@

require "http/base64"
require "http/chainable/helpers"
require "http/chainable/verbs"
require "http/headers"

module HTTP
# HTTP verb methods and client configuration DSL
module Chainable
include HTTP::Base64

# Request a get sans response body
#
# @example
# HTTP.head("http://example.com")
#
# @param [String, URI] uri URI to request
# @param [Hash] options request options
# @return [HTTP::Response]
# @api public
def head(uri, options = {})
request :head, uri, options
end

# Get a resource
#
# @example
# HTTP.get("http://example.com")
#
# @param [String, URI] uri URI to request
# @param [Hash] options request options
# @return [HTTP::Response]
# @api public
def get(uri, options = {})
request :get, uri, options
end

# Post to a resource
#
# @example
# HTTP.post("http://example.com", body: "data")
#
# @param [String, URI] uri URI to request
# @param [Hash] options request options
# @return [HTTP::Response]
# @api public
def post(uri, options = {})
request :post, uri, options
end

# Put to a resource
#
# @example
# HTTP.put("http://example.com", body: "data")
#
# @param [String, URI] uri URI to request
# @param [Hash] options request options
# @return [HTTP::Response]
# @api public
def put(uri, options = {})
request :put, uri, options
end

# Delete a resource
#
# @example
# HTTP.delete("http://example.com/resource")
#
# @param [String, URI] uri URI to request
# @param [Hash] options request options
# @return [HTTP::Response]
# @api public
def delete(uri, options = {})
request :delete, uri, options
end

# Echo the request back to the client
#
# @example
# HTTP.trace("http://example.com")
#
# @param [String, URI] uri URI to request
# @param [Hash] options request options
# @return [HTTP::Response]
# @api public
def trace(uri, options = {})
request :trace, uri, options
end

# Return the methods supported on the given URI
#
# @example
# HTTP.options("http://example.com")
#
# @param [String, URI] uri URI to request
# @param [Hash] options request options
# @return [HTTP::Response]
# @api public
def options(uri, options = {})
request :options, uri, options
end

# Convert to a transparent TCP/IP tunnel
#
# @example
# HTTP.connect("http://example.com")
#
# @param [String, URI] uri URI to request
# @param [Hash] options request options
# @return [HTTP::Response]
# @api public
def connect(uri, options = {})
request :connect, uri, options
end

# Apply partial modifications to a resource
#
# @example
# HTTP.patch("http://example.com/resource", body: "data")
#
# @param [String, URI] uri URI to request
# @param [Hash] options request options
# @return [HTTP::Response]
# @api public
def patch(uri, options = {})
request :patch, uri, options
end
include Verbs

# Make an HTTP request with the given verb
#
Expand All @@ -135,7 +20,7 @@ def patch(uri, options = {})
# @return [HTTP::Response]
# @api public
def request(verb, uri, opts = {})
branch(default_options).request(verb, uri, opts)
make_client(default_options).request(verb, uri, opts)
end

# Prepare an HTTP request with the given verb
Expand All @@ -147,7 +32,7 @@ def request(verb, uri, opts = {})
# @return [HTTP::Request]
# @api public
def build_request(verb, uri, opts = {})
branch(default_options).build_request(verb, uri, opts)
make_client(default_options).build_request(verb, uri, opts)
end

# Set timeout on the request
Expand All @@ -165,7 +50,7 @@ def build_request(verb, uri, opts = {})
# @overload timeout(global_timeout)
# Adds a global timeout to the full request
# @param [Numeric] global_timeout
# @return [HTTP::Client]
# @return [HTTP::Session]
# @api public
def timeout(options)
klass, options = case options
Expand Down Expand Up @@ -222,7 +107,8 @@ def timeout(options)
# @return [HTTP::Client, Object]
# @api public
def persistent(host, timeout: 5)
p_client = branch default_options.merge(keep_alive_timeout: timeout).with_persistent(host)
options = default_options.merge(keep_alive_timeout: timeout).with_persistent(host)
p_client = make_client(options)
return p_client unless block_given?

yield p_client
Expand All @@ -237,7 +123,7 @@ def persistent(host, timeout: 5)
#
# @param [Array] proxy
# @raise [Request::Error] if HTTP proxy is invalid
# @return [HTTP::Client]
# @return [HTTP::Session]
# @api public
def via(*proxy)
proxy_hash = build_proxy_hash(proxy)
Expand All @@ -254,7 +140,7 @@ def via(*proxy)
# HTTP.follow.get("http://example.com")
#
# @param [Hash] options redirect options
# @return [HTTP::Client]
# @return [HTTP::Session]
# @see Redirector#initialize
# @api public
def follow(options = {})
Expand All @@ -267,7 +153,7 @@ def follow(options = {})
# HTTP.headers("Accept" => "text/plain").get("http://example.com")
#
# @param [Hash] headers request headers
# @return [HTTP::Client]
# @return [HTTP::Session]
# @api public
def headers(headers)
branch default_options.with_headers(headers)
Expand All @@ -279,7 +165,7 @@ def headers(headers)
# HTTP.cookies(session: "abc123").get("http://example.com")
#
# @param [Hash] cookies cookies to set
# @return [HTTP::Client]
# @return [HTTP::Session]
# @api public
def cookies(cookies)
branch default_options.with_cookies(cookies)
Expand All @@ -291,7 +177,7 @@ def cookies(cookies)
# HTTP.encoding("UTF-8").get("http://example.com")
#
# @param [String, Encoding] encoding encoding to use
# @return [HTTP::Client]
# @return [HTTP::Session]
# @api public
def encoding(encoding)
branch default_options.with_encoding(encoding)
Expand All @@ -303,7 +189,7 @@ def encoding(encoding)
# HTTP.accept("application/json").get("http://example.com")
#
# @param [String, Symbol] type MIME type to accept
# @return [HTTP::Client]
# @return [HTTP::Session]
# @api public
def accept(type)
headers Headers::ACCEPT => MimeType.normalize(type)
Expand All @@ -315,7 +201,7 @@ def accept(type)
# HTTP.auth("Bearer token123").get("http://example.com")
#
# @param [#to_s] value Authorization header value
# @return [HTTP::Client]
# @return [HTTP::Session]
# @api public
def auth(value)
headers Headers::AUTHORIZATION => value.to_s
Expand All @@ -330,7 +216,7 @@ def auth(value)
# @param [#fetch] opts
# @option opts [#to_s] :user
# @option opts [#to_s] :pass
# @return [HTTP::Client]
# @return [HTTP::Session]
# @api public
def basic_auth(opts)
user = opts.fetch(:user)
Expand Down Expand Up @@ -368,7 +254,7 @@ def default_options=(opts)
# @example
# HTTP.nodelay.get("http://example.com")
#
# @return [HTTP::Client]
# @return [HTTP::Session]
# @api public
def nodelay
branch default_options.with_nodelay(true)
Expand All @@ -380,13 +266,13 @@ def nodelay
# HTTP.use(:auto_inflate).get("http://example.com")
#
# @param [Array<Symbol, Hash>] features features to enable
# @return [HTTP::Client]
# @return [HTTP::Session]
# @api public
def use(*features)
branch default_options.with_features(features)
end

# Return a retriable client that retries on failure
# Return a retriable session that retries on failure
#
# @example Usage
#
Expand All @@ -403,20 +289,29 @@ def use(*features)
# HTTP.retriable(tries: 3, delay: proc { |i| 1 + i*i }).get(url)
#
# @param (see Performer#initialize)
# @return [HTTP::Retriable::Client]
# @return [HTTP::Session]
# @api public
def retriable(**options)
Retriable::Client.new(Retriable::Performer.new(options), default_options)
branch default_options.with_retriable(options.empty? || options)
end

private

# Create a new client with the given options
# Create a new session with the given options
#
# @param [HTTP::Options] options options for the session
# @return [HTTP::Session]
# @api private
def branch(options)
HTTP::Session.new(options)
end

# Create a new client for executing a request
#
# @param [HTTP::Options] options options for the client
# @return [HTTP::Client]
# @api private
def branch(options)
def make_client(options)
HTTP::Client.new(options)
end
end
Expand Down
Loading