Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Stricter timeout options parsing #754

Open
wants to merge 6 commits into
base: main
Choose a base branch
from

Conversation

stoivo
Copy link

@stoivo stoivo commented Jun 8, 2023

fix #752

Move option transformation into then block since we want to extent it.
lib/http/timeout/per_operation.rb Show resolved Hide resolved
lib/http/timeout/per_operation.rb Outdated Show resolved Hide resolved
@@ -90,21 +90,18 @@ def build_request(*args)
# @overload timeout(global_timeout)
# Adds a global timeout to the full request
# @param [Numeric] global_timeout
PER_OPERATION_KEYS = Set.new(%i[read write connect])
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This looks unused :)

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

moved in into lib/http/timeout/per_operation.rb but didn't remove it from here

@stoivo
Copy link
Author

stoivo commented Jun 16, 2023

What are we waiting for?

@tarcieri
Copy link
Member

@stoivo the tests are red, for one thing

@stoivo
Copy link
Author

stoivo commented Jun 19, 2023

I think this is a flaky spec
Screenshot 2023-06-19 at 08 30 17

I will amend and push again just to check

Rubocop told me the method started to be too complex.

lib/http/chainable.rb:94:5: C: Metrics/PerceivedComplexity: Perceived complexity for timeout is too high. [10/8]
    def timeout(options) ...
    ^^^^^^^^^^^^^^^^^^^^
80 files inspected, 1 offense detected
@stoivo
Copy link
Author

stoivo commented Jun 19, 2023

Pushed with no changed and now spec are green

Copy link
Member

@ixti ixti left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

After taking a closer look, I think we should do parsing a bit differently:

KEYS = %i[read write connect].to_h { |k| [k, :"#{k}_timeout"] }.freeze

class << self
  def normalize_options(options)
    normalized = {}
    original   = options.dup

    KEYS.each do |short, long|
      if original.key?(short) && original.key(long)
        raise ArgumentError, "can't pass both #{short} and #{long}"
      end

      normalized[long] = original.key?(long) ? original.delete(long) : original.delete(short)
      raise ArgumentError, "#{long} must be numeric", unless normalized[long].is_a?(Numeric)
    end

    if original.size.positive?
      raise ArgumentError, "unknown timeout options: #{original.keys.join(', ')}"
    end

    if normalized.empty?
      raise ArgumentError, "no timeout options given"
    end

    normalized
  end
end

next unless options.key? k

if options.key?("#{k}_timeout".to_sym)
raise ArgumentError, "can't pass both #{k} and #{"#{k}_timeout".to_sym}"
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
raise ArgumentError, "can't pass both #{k} and #{"#{k}_timeout".to_sym}"
raise ArgumentError, "can't pass both #{k} and #{k}_timeout"


class << self
def parse_options(options)
options = options.dup.then { |opts| expand_names(opts) }
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
options = options.dup.then { |opts| expand_names(opts) }
options = expand_names(options.dup)

Comment on lines 19 to 28
options.each do |key, value|
unless SETTINGS.member?(key) && value.is_a?(Numeric)
raise ArgumentError, "invalid option #{key.inspect}, must be numeric " \
"`.timeout(connect: x, write: y, read: z)`."
end
end

raise ArgumentError, "at least one option" if options.empty?

options
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe we also should fail if given options are mistyped or totally wrong ones

Copy link
Author

@stoivo stoivo Jun 26, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is covered by

          options.each do |key, value|
            unless SETTINGS.member?(key) && value.is_a?(Numeric)
              raise ArgumentError, "invalid option #{key.inspect}, must be numeric " \
                                   "`.timeout(connect: x, write: y, read: z)`."
            end
          end

@stoivo
Copy link
Author

stoivo commented Jun 26, 2023

Very nice suggestion. So much more strait forward. It looks like it sneaked in a , in raise ArgumentError, "#{long} must be numeric", unless normalized[long].is_a?(Numeric)

After taking a closer look, I think we should do parsing a bit differently:

KEYS = %i[read write connect].to_h { |k| [k, :"#{k}_timeout"] }.freeze

class << self
  def normalize_options(options)
    normalized = {}
    original   = options.dup

    KEYS.each do |short, long|
      if original.key?(short) && original.key(long)
        raise ArgumentError, "can't pass both #{short} and #{long}"
      end

      normalized[long] = original.key?(long) ? original.delete(long) : original.delete(short)
      raise ArgumentError, "#{long} must be numeric", unless normalized[long].is_a?(Numeric)
    end

    if original.size.positive?
      raise ArgumentError, "unknown timeout options: #{original.keys.join(', ')}"
    end

    if normalized.empty?
      raise ArgumentError, "no timeout options given"
    end

    normalized
  end
end

You should be able to only set one of the actions callbacks like HTTP.timeout :read => 125?

rubocop offences
be rubocop -a                                                                                                                                                                                                               15:37:07
lib/http/timeout/per_operation.rb:17:9: C: Metrics/AbcSize: Assignment Branch Condition size for normalize_options is too high. [<5, 23, 9> 25.2/17]
        def normalize_options(options) ...
        ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
lib/http/timeout/per_operation.rb:17:9: C: Metrics/CyclomaticComplexity: Cyclomatic complexity for normalize_options is too high. [10/7]
        def normalize_options(options) ...
        ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
lib/http/timeout/per_operation.rb:17:9: C: Metrics/MethodLength: Method has too many lines. [11/10]
        def normalize_options(options) ...
        ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
lib/http/timeout/per_operation.rb:17:9: C: Metrics/PerceivedComplexity: Perceived complexity for normalize_options is too high. [10/8]
        def normalize_options(options) ...
        ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
 80/80 files |================================================================================================================ 100 =================================================================================================================>| Time: 00:00:00

80 files inspected, 4 offenses detected
@stoivo
Copy link
Author

stoivo commented Jun 26, 2023

I like how it looks but rubocop this it is too high complexity. I think it's nice

normalize_options instead of parse_options

rubocop offences
be rubocop -a                                                                                                                                                                                                               15:37:07
lib/http/timeout/per_operation.rb:17:9: C: Metrics/AbcSize: Assignment Branch Condition size for normalize_options is too high. [<5, 23, 9> 25.2/17]
        def normalize_options(options) ...
        ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
lib/http/timeout/per_operation.rb:17:9: C: Metrics/CyclomaticComplexity: Cyclomatic complexity for normalize_options is too high. [10/7]
        def normalize_options(options) ...
        ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
lib/http/timeout/per_operation.rb:17:9: C: Metrics/MethodLength: Method has too many lines. [11/10]
        def normalize_options(options) ...
        ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
lib/http/timeout/per_operation.rb:17:9: C: Metrics/PerceivedComplexity: Perceived complexity for normalize_options is too high. [10/8]
        def normalize_options(options) ...
        ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
 80/80 files 
80 files inspected, 4 offenses detected

@stoivo
Copy link
Author

stoivo commented Jul 7, 2023

@ixti ping

@ixti
Copy link
Member

ixti commented Jul 7, 2023

oh. sorry, totally forgot. I'm afk till Monday, will merge as soon as get back

@ixti
Copy link
Member

ixti commented Jul 14, 2023

I've added a commit that disables inline some cops for that method, but I believe I found a regression. Right now it's possible to set global timeout with:

HTTP.timeout({ global: 161 })

And now that I'm looking at all of this again, I think next major we should add breaking change and:

  • change #timeout signature to #timeout(type, ...)
  • type will be one of: :none (or nil), :global, :per_operation
  • signature of Global#initialize should be (value)
  • signature of PerOperation#initialize should be (connect: 0, write: 0, read: 0)

@tarcieri WDYT?

@tarcieri
Copy link
Member

Sounds fine to me although it would be a good time to consider potential other breaking changes too

@misalcedo
Copy link

misalcedo commented Dec 13, 2023

I was comparing this HTTP client to others in Ruby and one thing I would like to add for timeouts is that global and per-operation are not mutually exclusive. I may want a request to time out after 1 minute, but spend no more than 150ms on the TCP connection portion or spend no more than 10 seconds idle on a read or write operation.

An alternative interpretation is what HTTPX does, which is to treat read as a timeout reading the response, write as a timeout writing the request and global as a overall timeout.

In my experience, except for streaming, HTTP clients care about total request timeout and maybe connect timeout, but definitely not per-operation timeouts. Streaming typically cares about operation timeouts and only optionally cares about a total timeout.

Often clients measure time to first byte from the server as well as overall latency, so timeouts on both of those could be helpful.

I am happy to help implement this type of behavior for 6.X if that is the target version.

@tarcieri
Copy link
Member

@misalcedo I opened #773 to track a potential redesign of the timeout subsystem for http 6.x

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

raise error when invalid hash is passed to timeout
5 participants