berkeley_library-util 0.1.7 → 0.1.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.github/workflows/build.yml +1 -1
- data/CHANGES.md +9 -0
- data/lib/berkeley_library/util/module_info.rb +1 -1
- data/lib/berkeley_library/util/uris/exceptions.rb +15 -0
- data/lib/berkeley_library/util/uris/requester/class_methods.rb +89 -0
- data/lib/berkeley_library/util/uris/requester.rb +71 -68
- data/lib/berkeley_library/util/uris.rb +1 -1
- data/spec/berkeley_library/util/uris/requester_spec.rb +244 -0
- data/spec/berkeley_library/util/uris_spec.rb +8 -14
- metadata +4 -2
    
        checksums.yaml
    CHANGED
    
    | @@ -1,7 +1,7 @@ | |
| 1 1 | 
             
            ---
         | 
| 2 2 | 
             
            SHA256:
         | 
| 3 | 
            -
              metadata.gz:  | 
| 4 | 
            -
              data.tar.gz:  | 
| 3 | 
            +
              metadata.gz: fdc84acc6adb94fcfbbcf7d18b9a6bead8ef484ef40028f139b07964b565b4e2
         | 
| 4 | 
            +
              data.tar.gz: 43e850e1ee541d777c07f546f789e576b45a44580734206c44cd2dce4d3293a1
         | 
| 5 5 | 
             
            SHA512:
         | 
| 6 | 
            -
              metadata.gz:  | 
| 7 | 
            -
              data.tar.gz:  | 
| 6 | 
            +
              metadata.gz: dc47ba39cee501522db01aceeba7060f51ee4270ca03a2d4263e836190eddc2e953fd83eb292379ce4fcdbd5a9bac3831ec76c750cb2ecd9a375c86a60533079
         | 
| 7 | 
            +
              data.tar.gz: 0dc4190292083d68066528c6a7efa815667ccc6ba79fb75ba0d0fa5e5f1ab354465447d1d7b53bbf825426e010bd4954b0ada17f3b935ae68095d8d0795940c1
         | 
    
        data/.github/workflows/build.yml
    CHANGED
    
    
    
        data/CHANGES.md
    CHANGED
    
    | @@ -1,3 +1,12 @@ | |
| 1 | 
            +
            # 0.1.9 (2023-06-01)
         | 
| 2 | 
            +
             | 
| 3 | 
            +
            - `URIs#path_escape` now attempts to convert non-UTF-8 strings to UTF-8 rather than immediately
         | 
| 4 | 
            +
              raising an error.
         | 
| 5 | 
            +
             | 
| 6 | 
            +
            # 0.1.8 (2023-03-20)
         | 
| 7 | 
            +
             | 
| 8 | 
            +
            - Add `Retry-After` support to `Requester` for `429 Too Many Requests` and `503 Service Unavailable`. 
         | 
| 9 | 
            +
             | 
| 1 10 | 
             
            # 0.1.7 (2023-03-15)
         | 
| 2 11 |  | 
| 3 12 | 
             
            - Allow passing `log: false` to `Requester` methods (and corresponding `URIs` convenience
         | 
| @@ -7,7 +7,7 @@ module BerkeleyLibrary | |
| 7 7 | 
             
                  SUMMARY = 'Miscellaneous Ruby utilities for the UC Berkeley Library'.freeze
         | 
| 8 8 | 
             
                  DESCRIPTION = 'A collection of miscellaneous Ruby routines for the UC Berkeley Library.'.freeze
         | 
| 9 9 | 
             
                  LICENSE = 'MIT'.freeze
         | 
| 10 | 
            -
                  VERSION = '0.1. | 
| 10 | 
            +
                  VERSION = '0.1.9'.freeze
         | 
| 11 11 | 
             
                  HOMEPAGE = 'https://github.com/BerkeleyLibrary/util'.freeze
         | 
| 12 12 | 
             
                end
         | 
| 13 13 | 
             
              end
         | 
| @@ -0,0 +1,15 @@ | |
| 1 | 
            +
            class RetryDelayTooLarge < RestClient::Exception
         | 
| 2 | 
            +
              def initialize(response, delay:, max_delay:)
         | 
| 3 | 
            +
                super(response, response.code)
         | 
| 4 | 
            +
             | 
| 5 | 
            +
                self.message = 'Retry delay of %0.2gs exceeds limit of %0.2gs' % [delay, max_delay]
         | 
| 6 | 
            +
              end
         | 
| 7 | 
            +
            end
         | 
| 8 | 
            +
             | 
| 9 | 
            +
            class RetryLimitExceeded < RestClient::Exception
         | 
| 10 | 
            +
              def initialize(response, max_retries:)
         | 
| 11 | 
            +
                super(response, response.code)
         | 
| 12 | 
            +
             | 
| 13 | 
            +
                self.message = "Retry limit (#{max_retries}) exceeded"
         | 
| 14 | 
            +
              end
         | 
| 15 | 
            +
            end
         | 
| @@ -0,0 +1,89 @@ | |
| 1 | 
            +
            module BerkeleyLibrary
         | 
| 2 | 
            +
              module Util
         | 
| 3 | 
            +
                module URIs
         | 
| 4 | 
            +
                  class Requester
         | 
| 5 | 
            +
                    # rubocop:disable Metrics/ParameterLists
         | 
| 6 | 
            +
                    module ClassMethods
         | 
| 7 | 
            +
                      # Performs a GET request and returns the response body as a string.
         | 
| 8 | 
            +
                      #
         | 
| 9 | 
            +
                      # @param uri [URI, String] the URI to GET
         | 
| 10 | 
            +
                      # @param params [Hash] the query parameters to add to the URI. (Note that the URI may already include query parameters.)
         | 
| 11 | 
            +
                      # @param headers [Hash] the request headers.
         | 
| 12 | 
            +
                      # @return [String] the body as a string.
         | 
| 13 | 
            +
                      # @param log [Boolean] whether to log each request URL and response code
         | 
| 14 | 
            +
                      # @param max_retries [Integer] the maximum number of times to retry after a 429 or 503 with Retry-After
         | 
| 15 | 
            +
                      # @param max_retry_delay [Integer] the maximum retry delay (in seconds) to accept in a Retry-After header
         | 
| 16 | 
            +
                      # @raise [RestClient::Exception] in the event of an unsuccessful request.
         | 
| 17 | 
            +
                      def get(uri, params: {}, headers: {}, log: true, max_retries: MAX_RETRIES, max_retry_delay: MAX_RETRY_DELAY_SECONDS)
         | 
| 18 | 
            +
                        resp = make_request(:get, uri, params, headers, log, max_retries, max_retry_delay)
         | 
| 19 | 
            +
                        resp.body
         | 
| 20 | 
            +
                      end
         | 
| 21 | 
            +
             | 
| 22 | 
            +
                      # Performs a HEAD request and returns the response status as an integer.
         | 
| 23 | 
            +
                      # Note that unlike {Requester#get}, this does not raise an error in the
         | 
| 24 | 
            +
                      # event of an unsuccessful request.
         | 
| 25 | 
            +
                      #
         | 
| 26 | 
            +
                      # @param uri [URI, String] the URI to HEAD
         | 
| 27 | 
            +
                      # @param params [Hash] the query parameters to add to the URI. (Note that the URI may already include query parameters.)
         | 
| 28 | 
            +
                      # @param headers [Hash] the request headers.
         | 
| 29 | 
            +
                      # @param log [Boolean] whether to log each request URL and response code
         | 
| 30 | 
            +
                      # @return [Integer] the response code as an integer.
         | 
| 31 | 
            +
                      def head(uri, params: {}, headers: {}, log: true, max_retries: MAX_RETRIES, max_retry_delay: MAX_RETRY_DELAY_SECONDS)
         | 
| 32 | 
            +
                        head_response(uri, params: params, headers: headers, log: log, max_retries: max_retries, max_retry_delay: max_retry_delay).code
         | 
| 33 | 
            +
                      end
         | 
| 34 | 
            +
             | 
| 35 | 
            +
                      # Performs a GET request and returns the response, even in the event of
         | 
| 36 | 
            +
                      # a failed request.
         | 
| 37 | 
            +
                      #
         | 
| 38 | 
            +
                      # @param uri [URI, String] the URI to GET
         | 
| 39 | 
            +
                      # @param params [Hash] the query parameters to add to the URI. (Note that the URI may already include query parameters.)
         | 
| 40 | 
            +
                      # @param headers [Hash] the request headers.
         | 
| 41 | 
            +
                      # @param log [Boolean] whether to log each request URL and response code
         | 
| 42 | 
            +
                      # @return [RestClient::Response] the response
         | 
| 43 | 
            +
                      def get_response(uri, params: {}, headers: {}, log: true, max_retries: MAX_RETRIES, max_retry_delay: MAX_RETRY_DELAY_SECONDS)
         | 
| 44 | 
            +
                        make_request(:get, uri, params, headers, log, max_retries, max_retry_delay)
         | 
| 45 | 
            +
                      rescue RestClient::Exception => e
         | 
| 46 | 
            +
                        e.response
         | 
| 47 | 
            +
                      end
         | 
| 48 | 
            +
             | 
| 49 | 
            +
                      # Performs a HEAD request and returns the response, even in the event of
         | 
| 50 | 
            +
                      # a failed request.
         | 
| 51 | 
            +
                      #
         | 
| 52 | 
            +
                      # @param uri [URI, String] the URI to HEAD
         | 
| 53 | 
            +
                      # @param params [Hash] the query parameters to add to the URI. (Note that the URI may already include query parameters.)
         | 
| 54 | 
            +
                      # @param headers [Hash] the request headers.
         | 
| 55 | 
            +
                      # @param log [Boolean] whether to log each request URL and response code
         | 
| 56 | 
            +
                      # @return [RestClient::Response] the response
         | 
| 57 | 
            +
                      def head_response(uri, params: {}, headers: {}, log: true, max_retries: MAX_RETRIES, max_retry_delay: MAX_RETRY_DELAY_SECONDS)
         | 
| 58 | 
            +
                        make_request(:head, uri, params, headers, log, max_retries, max_retry_delay)
         | 
| 59 | 
            +
                      rescue RestClient::Exception => e
         | 
| 60 | 
            +
                        e.response
         | 
| 61 | 
            +
                      end
         | 
| 62 | 
            +
             | 
| 63 | 
            +
                      private
         | 
| 64 | 
            +
             | 
| 65 | 
            +
                      def make_request(method, url, params, headers, log, max_retries, max_retry_delay)
         | 
| 66 | 
            +
                        Requester.new(
         | 
| 67 | 
            +
                          method,
         | 
| 68 | 
            +
                          url,
         | 
| 69 | 
            +
                          params: params,
         | 
| 70 | 
            +
                          headers: headers,
         | 
| 71 | 
            +
                          log: log,
         | 
| 72 | 
            +
                          max_retries: max_retries,
         | 
| 73 | 
            +
                          max_retry_delay: max_retry_delay
         | 
| 74 | 
            +
                        ).make_request
         | 
| 75 | 
            +
                      end
         | 
| 76 | 
            +
             | 
| 77 | 
            +
                    end
         | 
| 78 | 
            +
                    # rubocop:enable Metrics/ParameterLists
         | 
| 79 | 
            +
             | 
| 80 | 
            +
                    # ------------------------------------------------------------
         | 
| 81 | 
            +
                    # Class methods
         | 
| 82 | 
            +
             | 
| 83 | 
            +
                    class << self
         | 
| 84 | 
            +
                      include ClassMethods
         | 
| 85 | 
            +
                    end
         | 
| 86 | 
            +
                  end
         | 
| 87 | 
            +
                end
         | 
| 88 | 
            +
              end
         | 
| 89 | 
            +
            end
         | 
| @@ -1,6 +1,9 @@ | |
| 1 | 
            +
            require 'time'
         | 
| 1 2 | 
             
            require 'rest-client'
         | 
| 2 3 | 
             
            require 'berkeley_library/util/uris/appender'
         | 
| 4 | 
            +
            require 'berkeley_library/util/uris/exceptions'
         | 
| 3 5 | 
             
            require 'berkeley_library/util/uris/validator'
         | 
| 6 | 
            +
            require 'berkeley_library/util/uris/requester/class_methods'
         | 
| 4 7 | 
             
            require 'berkeley_library/logging'
         | 
| 5 8 |  | 
| 6 9 | 
             
            module BerkeleyLibrary
         | 
| @@ -9,80 +12,19 @@ module BerkeleyLibrary | |
| 9 12 | 
             
                  class Requester
         | 
| 10 13 | 
             
                    include BerkeleyLibrary::Logging
         | 
| 11 14 |  | 
| 12 | 
            -
                    # ------------------------------------------------------------
         | 
| 13 | 
            -
                    # Class methods
         | 
| 14 | 
            -
             | 
| 15 | 
            -
                    class << self
         | 
| 16 | 
            -
                      # Performs a GET request and returns the response body as a string.
         | 
| 17 | 
            -
                      #
         | 
| 18 | 
            -
                      # @param uri [URI, String] the URI to GET
         | 
| 19 | 
            -
                      # @param params [Hash] the query parameters to add to the URI. (Note that the URI may already include query parameters.)
         | 
| 20 | 
            -
                      # @param headers [Hash] the request headers.
         | 
| 21 | 
            -
                      # @return [String] the body as a string.
         | 
| 22 | 
            -
                      # @param log [Boolean] whether to log each request URL and response code
         | 
| 23 | 
            -
                      # @raise [RestClient::Exception] in the event of an unsuccessful request.
         | 
| 24 | 
            -
                      def get(uri, params: {}, headers: {}, log: true)
         | 
| 25 | 
            -
                        resp = make_request(:get, uri, params, headers, log)
         | 
| 26 | 
            -
                        resp.body
         | 
| 27 | 
            -
                      end
         | 
| 28 | 
            -
             | 
| 29 | 
            -
                      # Performs a HEAD request and returns the response status as an integer.
         | 
| 30 | 
            -
                      # Note that unlike {Requester#get}, this does not raise an error in the
         | 
| 31 | 
            -
                      # event of an unsuccessful request.
         | 
| 32 | 
            -
                      #
         | 
| 33 | 
            -
                      # @param uri [URI, String] the URI to HEAD
         | 
| 34 | 
            -
                      # @param params [Hash] the query parameters to add to the URI. (Note that the URI may already include query parameters.)
         | 
| 35 | 
            -
                      # @param headers [Hash] the request headers.
         | 
| 36 | 
            -
                      # @param log [Boolean] whether to log each request URL and response code
         | 
| 37 | 
            -
                      # @return [Integer] the response code as an integer.
         | 
| 38 | 
            -
                      def head(uri, params: {}, headers: {}, log: true)
         | 
| 39 | 
            -
                        head_response(uri, params: params, headers: headers, log: log).code
         | 
| 40 | 
            -
                      end
         | 
| 41 | 
            -
             | 
| 42 | 
            -
                      # Performs a GET request and returns the response, even in the event of
         | 
| 43 | 
            -
                      # a failed request.
         | 
| 44 | 
            -
                      #
         | 
| 45 | 
            -
                      # @param uri [URI, String] the URI to GET
         | 
| 46 | 
            -
                      # @param params [Hash] the query parameters to add to the URI. (Note that the URI may already include query parameters.)
         | 
| 47 | 
            -
                      # @param headers [Hash] the request headers.
         | 
| 48 | 
            -
                      # @param log [Boolean] whether to log each request URL and response code
         | 
| 49 | 
            -
                      # @return [RestClient::Response] the body as a string.
         | 
| 50 | 
            -
                      def get_response(uri, params: {}, headers: {}, log: true)
         | 
| 51 | 
            -
                        make_request(:get, uri, params, headers, log)
         | 
| 52 | 
            -
                      rescue RestClient::Exception => e
         | 
| 53 | 
            -
                        e.response
         | 
| 54 | 
            -
                      end
         | 
| 55 | 
            -
             | 
| 56 | 
            -
                      # Performs a HEAD request and returns the response, even in the event of
         | 
| 57 | 
            -
                      # a failed request.
         | 
| 58 | 
            -
                      #
         | 
| 59 | 
            -
                      # @param uri [URI, String] the URI to HEAD
         | 
| 60 | 
            -
                      # @param params [Hash] the query parameters to add to the URI. (Note that the URI may already include query parameters.)
         | 
| 61 | 
            -
                      # @param headers [Hash] the request headers.
         | 
| 62 | 
            -
                      # @param log [Boolean] whether to log each request URL and response code
         | 
| 63 | 
            -
                      # @return [RestClient::Response] the response
         | 
| 64 | 
            -
                      def head_response(uri, params: {}, headers: {}, log: true)
         | 
| 65 | 
            -
                        make_request(:head, uri, params, headers, log)
         | 
| 66 | 
            -
                      rescue RestClient::Exception => e
         | 
| 67 | 
            -
                        e.response
         | 
| 68 | 
            -
                      end
         | 
| 69 | 
            -
             | 
| 70 | 
            -
                      private
         | 
| 71 | 
            -
             | 
| 72 | 
            -
                      def make_request(method, url, params, headers, log)
         | 
| 73 | 
            -
                        Requester.new(method, url, params: params, headers: headers, log: log).make_request
         | 
| 74 | 
            -
                      end
         | 
| 75 | 
            -
                    end
         | 
| 76 | 
            -
             | 
| 77 15 | 
             
                    # ------------------------------------------------------------
         | 
| 78 16 | 
             
                    # Constants
         | 
| 79 17 |  | 
| 80 18 | 
             
                    SUPPORTED_METHODS = %i[get head].freeze
         | 
| 19 | 
            +
                    RETRY_HEADER = :retry_after
         | 
| 20 | 
            +
                    RETRY_STATUSES = [429, 503].freeze
         | 
| 21 | 
            +
                    MAX_RETRY_DELAY_SECONDS = 10
         | 
| 22 | 
            +
                    MAX_RETRIES = 3
         | 
| 81 23 |  | 
| 82 24 | 
             
                    # ------------------------------------------------------------
         | 
| 83 25 | 
             
                    # Attributes
         | 
| 84 26 |  | 
| 85 | 
            -
                    attr_reader :method, :url_str, :headers, :log
         | 
| 27 | 
            +
                    attr_reader :method, :url_str, :headers, :log, :max_retries, :max_retry_delay
         | 
| 86 28 |  | 
| 87 29 | 
             
                    # ------------------------------------------------------------
         | 
| 88 30 | 
             
                    # Initializer
         | 
| @@ -94,8 +36,11 @@ module BerkeleyLibrary | |
| 94 36 | 
             
                    # @param params [Hash] the query parameters to add to the URI. (Note that the URI may already include query parameters.)
         | 
| 95 37 | 
             
                    # @param headers [Hash] the request headers.
         | 
| 96 38 | 
             
                    # @param log [Boolean] whether to log each request URL and response code
         | 
| 39 | 
            +
                    # @param max_retries [Integer] the maximum number of times to retry after a 429 or 503 with Retry-After
         | 
| 40 | 
            +
                    # @param max_retry_delay [Integer] the maximum retry delay (in seconds) to accept in a Retry-After header
         | 
| 97 41 | 
             
                    # @raise URI::InvalidURIError if the specified URL is invalid
         | 
| 98 | 
            -
                     | 
| 42 | 
            +
                    # rubocop:disable Metrics/ParameterLists
         | 
| 43 | 
            +
                    def initialize(method, url, params: {}, headers: {}, log: true, max_retries: MAX_RETRIES, max_retry_delay: MAX_RETRY_DELAY_SECONDS)
         | 
| 99 44 | 
             
                      raise ArgumentError, "#{method} not supported" unless SUPPORTED_METHODS.include?(method)
         | 
| 100 45 | 
             
                      raise ArgumentError, 'url cannot be nil' unless (uri = Validator.uri_or_nil(url))
         | 
| 101 46 |  | 
| @@ -103,8 +48,12 @@ module BerkeleyLibrary | |
| 103 48 | 
             
                      @url_str = url_str_with_params(uri, params)
         | 
| 104 49 | 
             
                      @headers = headers
         | 
| 105 50 | 
             
                      @log = log
         | 
| 51 | 
            +
                      @max_retries = max_retries
         | 
| 52 | 
            +
                      @max_retry_delay = max_retry_delay
         | 
| 106 53 | 
             
                    end
         | 
| 107 54 |  | 
| 55 | 
            +
                    # rubocop:enable Metrics/ParameterLists
         | 
| 56 | 
            +
             | 
| 108 57 | 
             
                    # ------------------------------------------------------------
         | 
| 109 58 | 
             
                    # Public instance methods
         | 
| 110 59 |  | 
| @@ -139,13 +88,30 @@ module BerkeleyLibrary | |
| 139 88 | 
             
                      Appender.new(*elements).to_url_str
         | 
| 140 89 | 
             
                    end
         | 
| 141 90 |  | 
| 142 | 
            -
                    def execute_request
         | 
| 91 | 
            +
                    def execute_request(retries_remaining = max_retries)
         | 
| 92 | 
            +
                      try_execute_request
         | 
| 93 | 
            +
                    rescue RestClient::Exception => e
         | 
| 94 | 
            +
                      response = e.response
         | 
| 95 | 
            +
                      raise unless (retry_delay = retry_delay_from(response))
         | 
| 96 | 
            +
             | 
| 97 | 
            +
                      wait_for_retry(response, retry_delay, retries_remaining)
         | 
| 98 | 
            +
                      execute_request(retries_remaining - 1)
         | 
| 99 | 
            +
                    end
         | 
| 100 | 
            +
             | 
| 101 | 
            +
                    def try_execute_request
         | 
| 143 102 | 
             
                      RestClient::Request.execute(method: method, url: url_str, headers: headers).tap do |response|
         | 
| 144 103 | 
             
                        # Not all failed RestClient requests throw exceptions
         | 
| 145 104 | 
             
                        raise(exception_for(response)) unless response.code == 200
         | 
| 146 105 | 
             
                      end
         | 
| 147 106 | 
             
                    end
         | 
| 148 107 |  | 
| 108 | 
            +
                    def wait_for_retry(response, retry_delay, retries_remaining)
         | 
| 109 | 
            +
                      raise RetryLimitExceeded.new(response, max_retries: max_retries) unless retries_remaining > 0
         | 
| 110 | 
            +
                      raise RetryDelayTooLarge.new(response, delay: retry_delay, max_delay: max_retry_delay) if retry_delay > max_retry_delay
         | 
| 111 | 
            +
             | 
| 112 | 
            +
                      sleep(retry_delay)
         | 
| 113 | 
            +
                    end
         | 
| 114 | 
            +
             | 
| 149 115 | 
             
                    def exception_for(resp)
         | 
| 150 116 | 
             
                      status = resp.code
         | 
| 151 117 | 
             
                      ex_class_for(status).new(resp, status).tap do |ex|
         | 
| @@ -158,6 +124,43 @@ module BerkeleyLibrary | |
| 158 124 | 
             
                      RestClient::Exceptions::EXCEPTIONS_MAP[status] || RestClient::RequestFailed
         | 
| 159 125 | 
             
                    end
         | 
| 160 126 |  | 
| 127 | 
            +
                    # Returns the retry interval for the specified exception, or `nil`
         | 
| 128 | 
            +
                    # if the response does not allow a retry.
         | 
| 129 | 
            +
                    #
         | 
| 130 | 
            +
                    # @param resp [RestClient::Response] the response
         | 
| 131 | 
            +
                    # @return [Integer, nil] the retry delay in seconds, or `nil` if the response
         | 
| 132 | 
            +
                    #         does not allow a retry
         | 
| 133 | 
            +
                    def retry_delay_from(resp)
         | 
| 134 | 
            +
                      return unless RETRY_STATUSES.include?(resp.code)
         | 
| 135 | 
            +
                      return unless (retry_header_value = resp.headers[RETRY_HEADER])
         | 
| 136 | 
            +
                      return unless (retry_delay_seconds = parse_retry_header_value(retry_header_value))
         | 
| 137 | 
            +
             | 
| 138 | 
            +
                      [1, retry_delay_seconds.ceil].max
         | 
| 139 | 
            +
                    end
         | 
| 140 | 
            +
             | 
| 141 | 
            +
                    # @return [Float, nil] the retry delay in seconds, or `nil` if the delay cannot be parsed
         | 
| 142 | 
            +
                    def parse_retry_header_value(v)
         | 
| 143 | 
            +
                      # start by assuming it's a delay in seconds
         | 
| 144 | 
            +
                      Float(v) # should be an integer but let's not count on it
         | 
| 145 | 
            +
                    rescue ArgumentError
         | 
| 146 | 
            +
                      # assume it's an HTTP-date
         | 
| 147 | 
            +
                      parse_retry_after_date(v)
         | 
| 148 | 
            +
                    end
         | 
| 149 | 
            +
             | 
| 150 | 
            +
                    # Parses the specified RFC2822 datetime string and returns the interval between that
         | 
| 151 | 
            +
                    # datetime and the current time in seconds
         | 
| 152 | 
            +
                    #
         | 
| 153 | 
            +
                    # @param date_str [String] an RFC2822 datetime string
         | 
| 154 | 
            +
                    # @return [Float, nil] the interval between the current time and the specified datetime,
         | 
| 155 | 
            +
                    #   or nil if `date_str` cannot be parsed
         | 
| 156 | 
            +
                    def parse_retry_after_date(date_str)
         | 
| 157 | 
            +
                      retry_after = DateTime.rfc2822(date_str).to_time
         | 
| 158 | 
            +
                      retry_after - Time.now
         | 
| 159 | 
            +
                    rescue ArgumentError
         | 
| 160 | 
            +
                      logger.warn("Can't parse #{RETRY_HEADER} value #{date_str}")
         | 
| 161 | 
            +
                      nil
         | 
| 162 | 
            +
                    end
         | 
| 163 | 
            +
             | 
| 161 164 | 
             
                  end
         | 
| 162 165 | 
             
                end
         | 
| 163 166 | 
             
              end
         | 
| @@ -88,8 +88,8 @@ module BerkeleyLibrary | |
| 88 88 | 
             
                  # replacing disallowed characters (including /) with percent-encodings as needed.
         | 
| 89 89 | 
             
                  def path_escape(s)
         | 
| 90 90 | 
             
                    raise ArgumentError, "Can't escape #{s.inspect}: not a string" unless s.respond_to?(:encoding)
         | 
| 91 | 
            -
                    raise ArgumentError, "Can't escape #{s.inspect}: expected #{UTF_8}, was #{s.encoding}" unless s.encoding == UTF_8
         | 
| 92 91 |  | 
| 92 | 
            +
                    s = s.encode(UTF_8) unless s.encoding == UTF_8
         | 
| 93 93 | 
             
                    ''.tap do |escaped|
         | 
| 94 94 | 
             
                      s.bytes.each do |b|
         | 
| 95 95 | 
             
                        escaped << (should_escape?(b, :path_segment) ? '%%%02X' % b : b.chr)
         | 
| @@ -65,6 +65,250 @@ module BerkeleyLibrary | |
| 65 65 | 
             
                        result = Requester.get(url1)
         | 
| 66 66 | 
             
                        expect(result).to eq(expected_body)
         | 
| 67 67 | 
             
                      end
         | 
| 68 | 
            +
             | 
| 69 | 
            +
                      describe 'retries' do
         | 
| 70 | 
            +
                        let(:url) { 'https://example.org/' }
         | 
| 71 | 
            +
                        let(:expected_body) { 'Help! I am trapped in a unit test' }
         | 
| 72 | 
            +
             | 
| 73 | 
            +
                        context 'handling 429 Too Many Requests' do
         | 
| 74 | 
            +
                          context 'with Retry-After' do
         | 
| 75 | 
            +
                            context 'in seconds' do
         | 
| 76 | 
            +
                              it 'retries after the specified delay' do
         | 
| 77 | 
            +
                                retry_after_seconds = 1
         | 
| 78 | 
            +
             | 
| 79 | 
            +
                                stub_request(:get, url)
         | 
| 80 | 
            +
                                  .to_return(status: 429, headers: { 'Retry-After' => retry_after_seconds.to_s })
         | 
| 81 | 
            +
                                  .to_return(status: 200, body: expected_body)
         | 
| 82 | 
            +
             | 
| 83 | 
            +
                                requester = Requester.new(:get, url)
         | 
| 84 | 
            +
                                expect(requester).to receive(:sleep).with(1).once
         | 
| 85 | 
            +
             | 
| 86 | 
            +
                                result = requester.make_request
         | 
| 87 | 
            +
                                expect(result).to eq(expected_body)
         | 
| 88 | 
            +
                              end
         | 
| 89 | 
            +
             | 
| 90 | 
            +
                              it 'handles a non-integer retry delay' do
         | 
| 91 | 
            +
                                retry_after_seconds = 1.5
         | 
| 92 | 
            +
                                stub_request(:get, url)
         | 
| 93 | 
            +
                                  .to_return(status: 429, headers: { 'Retry-After' => retry_after_seconds.to_s })
         | 
| 94 | 
            +
                                  .to_return(status: 200, body: expected_body)
         | 
| 95 | 
            +
             | 
| 96 | 
            +
                                requester = Requester.new(:get, url)
         | 
| 97 | 
            +
                                expect(requester).to receive(:sleep).with(2).once
         | 
| 98 | 
            +
             | 
| 99 | 
            +
                                result = requester.make_request
         | 
| 100 | 
            +
                                expect(result).to eq(expected_body)
         | 
| 101 | 
            +
                              end
         | 
| 102 | 
            +
             | 
| 103 | 
            +
                              it 'raises RetryDelayTooLarge if the delay is too large' do
         | 
| 104 | 
            +
                                retry_after_seconds = 10 + BerkeleyLibrary::Util::URIs::Requester::MAX_RETRY_DELAY_SECONDS
         | 
| 105 | 
            +
             | 
| 106 | 
            +
                                stub_request(:get, url)
         | 
| 107 | 
            +
                                  .to_return(status: 429, headers: { 'Retry-After' => retry_after_seconds.to_s })
         | 
| 108 | 
            +
             | 
| 109 | 
            +
                                requester = Requester.new(:get, url)
         | 
| 110 | 
            +
                                expect(requester).not_to receive(:sleep)
         | 
| 111 | 
            +
             | 
| 112 | 
            +
                                expect { requester.make_request }.to raise_error(RetryDelayTooLarge) do |ex|
         | 
| 113 | 
            +
                                  expect(ex.cause).to be_a(RestClient::TooManyRequests)
         | 
| 114 | 
            +
                                end
         | 
| 115 | 
            +
                              end
         | 
| 116 | 
            +
             | 
| 117 | 
            +
                              it 'raises RetryLimitExceeded if there are too many retries' do
         | 
| 118 | 
            +
                                retry_after_seconds = 1
         | 
| 119 | 
            +
             | 
| 120 | 
            +
                                stub_request(:get, url)
         | 
| 121 | 
            +
                                  .to_return(status: 429, headers: { 'Retry-After' => retry_after_seconds.to_s })
         | 
| 122 | 
            +
                                  .to_return(status: 429, headers: { 'Retry-After' => retry_after_seconds.to_s })
         | 
| 123 | 
            +
             | 
| 124 | 
            +
                                requester = Requester.new(:get, url, max_retries: 1)
         | 
| 125 | 
            +
                                expect(requester).to receive(:sleep).with(1).once
         | 
| 126 | 
            +
             | 
| 127 | 
            +
                                expect { requester.make_request }.to raise_error(RetryLimitExceeded) do |ex|
         | 
| 128 | 
            +
                                  expect(ex.cause).to be_a(RestClient::TooManyRequests)
         | 
| 129 | 
            +
                                end
         | 
| 130 | 
            +
                              end
         | 
| 131 | 
            +
                            end
         | 
| 132 | 
            +
             | 
| 133 | 
            +
                            context 'as RFC2822 datetime' do
         | 
| 134 | 
            +
                              it 'retries after the specified delay' do
         | 
| 135 | 
            +
                                retry_after_seconds = 1
         | 
| 136 | 
            +
                                retry_after_datetime = (Time.now + retry_after_seconds)
         | 
| 137 | 
            +
             | 
| 138 | 
            +
                                stub_request(:get, url)
         | 
| 139 | 
            +
                                  .to_return(status: 429, headers: { 'Retry-After' => retry_after_datetime.rfc2822 })
         | 
| 140 | 
            +
                                  .to_return(status: 200, body: expected_body)
         | 
| 141 | 
            +
             | 
| 142 | 
            +
                                requester = Requester.new(:get, url)
         | 
| 143 | 
            +
                                expect(requester).to receive(:sleep).with(1).once
         | 
| 144 | 
            +
             | 
| 145 | 
            +
                                result = requester.make_request
         | 
| 146 | 
            +
                                expect(result).to eq(expected_body)
         | 
| 147 | 
            +
                              end
         | 
| 148 | 
            +
             | 
| 149 | 
            +
                              it 'handles a non-integer retry delay' do
         | 
| 150 | 
            +
                                retry_after_seconds = 2.75
         | 
| 151 | 
            +
                                retry_after_datetime = (Time.now + retry_after_seconds)
         | 
| 152 | 
            +
             | 
| 153 | 
            +
                                stub_request(:get, url)
         | 
| 154 | 
            +
                                  .to_return(status: 429, headers: { 'Retry-After' => retry_after_datetime.rfc2822 })
         | 
| 155 | 
            +
                                  .to_return(status: 200, body: expected_body)
         | 
| 156 | 
            +
             | 
| 157 | 
            +
                                requester = Requester.new(:get, url)
         | 
| 158 | 
            +
                                expected_value = a_value_within(1).of(retry_after_seconds)
         | 
| 159 | 
            +
                                expect(requester).to receive(:sleep).with(expected_value).once
         | 
| 160 | 
            +
             | 
| 161 | 
            +
                                result = requester.make_request
         | 
| 162 | 
            +
                                expect(result).to eq(expected_body)
         | 
| 163 | 
            +
                              end
         | 
| 164 | 
            +
             | 
| 165 | 
            +
                              it 'raises RetryDelayTooLarge if the delay is too large' do
         | 
| 166 | 
            +
                                retry_after_seconds = 10 + BerkeleyLibrary::Util::URIs::Requester::MAX_RETRY_DELAY_SECONDS
         | 
| 167 | 
            +
                                retry_after_datetime = (Time.now + retry_after_seconds)
         | 
| 168 | 
            +
             | 
| 169 | 
            +
                                stub_request(:get, url)
         | 
| 170 | 
            +
                                  .to_return(status: 429, headers: { 'Retry-After' => retry_after_datetime.rfc2822 })
         | 
| 171 | 
            +
             | 
| 172 | 
            +
                                requester = Requester.new(:get, url)
         | 
| 173 | 
            +
                                expect(requester).not_to receive(:sleep)
         | 
| 174 | 
            +
             | 
| 175 | 
            +
                                expect { requester.make_request }.to raise_error(RetryDelayTooLarge) do |ex|
         | 
| 176 | 
            +
                                  expect(ex.cause).to be_a(RestClient::TooManyRequests)
         | 
| 177 | 
            +
                                end
         | 
| 178 | 
            +
                              end
         | 
| 179 | 
            +
             | 
| 180 | 
            +
                              it 'raises RetryLimitExceeded if there are too many retries' do
         | 
| 181 | 
            +
                                retry_after_seconds = 1
         | 
| 182 | 
            +
                                retry_after_datetime = (Time.now + retry_after_seconds)
         | 
| 183 | 
            +
             | 
| 184 | 
            +
                                stub_request(:get, url)
         | 
| 185 | 
            +
                                  .to_return(status: 429, headers: { 'Retry-After' => retry_after_datetime.rfc2822 })
         | 
| 186 | 
            +
                                  .to_return(status: 429, headers: { 'Retry-After' => retry_after_datetime.rfc2822 })
         | 
| 187 | 
            +
             | 
| 188 | 
            +
                                requester = Requester.new(:get, url, max_retries: 1)
         | 
| 189 | 
            +
                                expect(requester).to receive(:sleep).with(1).once
         | 
| 190 | 
            +
             | 
| 191 | 
            +
                                expect { requester.make_request }.to raise_error(RetryLimitExceeded) do |ex|
         | 
| 192 | 
            +
                                  expect(ex.cause).to be_a(RestClient::TooManyRequests)
         | 
| 193 | 
            +
                                end
         | 
| 194 | 
            +
                              end
         | 
| 195 | 
            +
             | 
| 196 | 
            +
                            end
         | 
| 197 | 
            +
             | 
| 198 | 
            +
                            it 'ignores an invalid Retry-After' do
         | 
| 199 | 
            +
                              stub_request(:get, url)
         | 
| 200 | 
            +
                                .to_return(status: 429, headers: { 'Retry-After' => 'the end of the world' })
         | 
| 201 | 
            +
             | 
| 202 | 
            +
                              requester = Requester.new(:get, url)
         | 
| 203 | 
            +
                              expect { requester.make_request }.to raise_error(RestClient::TooManyRequests)
         | 
| 204 | 
            +
                            end
         | 
| 205 | 
            +
                          end
         | 
| 206 | 
            +
                        end
         | 
| 207 | 
            +
             | 
| 208 | 
            +
                        context 'handling 503 Service Unavailable' do
         | 
| 209 | 
            +
                          context 'with Retry-After' do
         | 
| 210 | 
            +
                            context 'in seconds' do
         | 
| 211 | 
            +
                              it 'retries after the specified delay' do
         | 
| 212 | 
            +
                                retry_after_seconds = 1
         | 
| 213 | 
            +
             | 
| 214 | 
            +
                                stub_request(:get, url)
         | 
| 215 | 
            +
                                  .to_return(status: 503, headers: { 'Retry-After' => retry_after_seconds.to_s })
         | 
| 216 | 
            +
                                  .to_return(status: 200, body: expected_body)
         | 
| 217 | 
            +
             | 
| 218 | 
            +
                                requester = Requester.new(:get, url)
         | 
| 219 | 
            +
                                expect(requester).to receive(:sleep).with(1).once
         | 
| 220 | 
            +
             | 
| 221 | 
            +
                                result = requester.make_request
         | 
| 222 | 
            +
                                expect(result).to eq(expected_body)
         | 
| 223 | 
            +
                              end
         | 
| 224 | 
            +
             | 
| 225 | 
            +
                              it 'handles a non-integer retry delay' do
         | 
| 226 | 
            +
                                retry_after_seconds = 0.75
         | 
| 227 | 
            +
                                stub_request(:get, url)
         | 
| 228 | 
            +
                                  .to_return(status: 503, headers: { 'Retry-After' => retry_after_seconds.to_s })
         | 
| 229 | 
            +
                                  .to_return(status: 200, body: expected_body)
         | 
| 230 | 
            +
             | 
| 231 | 
            +
                                requester = Requester.new(:get, url)
         | 
| 232 | 
            +
                                expect(requester).to receive(:sleep).with(1).once
         | 
| 233 | 
            +
             | 
| 234 | 
            +
                                result = requester.make_request
         | 
| 235 | 
            +
                                expect(result).to eq(expected_body)
         | 
| 236 | 
            +
                              end
         | 
| 237 | 
            +
             | 
| 238 | 
            +
                              it 'raises RetryDelayTooLarge if the delay is too large' do
         | 
| 239 | 
            +
                                retry_after_seconds = 10 + BerkeleyLibrary::Util::URIs::Requester::MAX_RETRY_DELAY_SECONDS
         | 
| 240 | 
            +
             | 
| 241 | 
            +
                                stub_request(:get, url)
         | 
| 242 | 
            +
                                  .to_return(status: 503, headers: { 'Retry-After' => retry_after_seconds.to_s })
         | 
| 243 | 
            +
             | 
| 244 | 
            +
                                requester = Requester.new(:get, url)
         | 
| 245 | 
            +
                                expect(requester).not_to receive(:sleep)
         | 
| 246 | 
            +
             | 
| 247 | 
            +
                                expect { requester.make_request }.to raise_error(RetryDelayTooLarge) do |ex|
         | 
| 248 | 
            +
                                  expect(ex.cause).to be_a(RestClient::ServiceUnavailable)
         | 
| 249 | 
            +
                                end
         | 
| 250 | 
            +
                              end
         | 
| 251 | 
            +
                            end
         | 
| 252 | 
            +
             | 
| 253 | 
            +
                            context 'as RFC2822 datetime' do
         | 
| 254 | 
            +
                              it 'retries after the specified delay' do
         | 
| 255 | 
            +
                                retry_after_seconds = 1
         | 
| 256 | 
            +
                                retry_after_datetime = (Time.now + retry_after_seconds)
         | 
| 257 | 
            +
             | 
| 258 | 
            +
                                stub_request(:get, url)
         | 
| 259 | 
            +
                                  .to_return(status: 503, headers: { 'Retry-After' => retry_after_datetime.rfc2822 })
         | 
| 260 | 
            +
                                  .to_return(status: 200, body: expected_body)
         | 
| 261 | 
            +
             | 
| 262 | 
            +
                                requester = Requester.new(:get, url)
         | 
| 263 | 
            +
                                expect(requester).to receive(:sleep).with(1).once
         | 
| 264 | 
            +
             | 
| 265 | 
            +
                                result = requester.make_request
         | 
| 266 | 
            +
                                expect(result).to eq(expected_body)
         | 
| 267 | 
            +
                              end
         | 
| 268 | 
            +
             | 
| 269 | 
            +
                              it 'handles a non-integer retry delay' do
         | 
| 270 | 
            +
                                retry_after_seconds = 1.75
         | 
| 271 | 
            +
                                retry_after_datetime = (Time.now + retry_after_seconds)
         | 
| 272 | 
            +
             | 
| 273 | 
            +
                                stub_request(:get, url)
         | 
| 274 | 
            +
                                  .to_return(status: 503, headers: { 'Retry-After' => retry_after_datetime.rfc2822 })
         | 
| 275 | 
            +
                                  .to_return(status: 200, body: expected_body)
         | 
| 276 | 
            +
             | 
| 277 | 
            +
                                requester = Requester.new(:get, url)
         | 
| 278 | 
            +
                                expected_value = a_value_within(1).of(retry_after_seconds)
         | 
| 279 | 
            +
                                expect(requester).to receive(:sleep).with(expected_value).once
         | 
| 280 | 
            +
             | 
| 281 | 
            +
                                result = requester.make_request
         | 
| 282 | 
            +
                                expect(result).to eq(expected_body)
         | 
| 283 | 
            +
                              end
         | 
| 284 | 
            +
             | 
| 285 | 
            +
                              it 'raises RetryDelayTooLarge if the delay is too large' do
         | 
| 286 | 
            +
                                retry_after_seconds = 10 + BerkeleyLibrary::Util::URIs::Requester::MAX_RETRY_DELAY_SECONDS
         | 
| 287 | 
            +
                                retry_after_datetime = (Time.now + retry_after_seconds)
         | 
| 288 | 
            +
             | 
| 289 | 
            +
                                stub_request(:get, url)
         | 
| 290 | 
            +
                                  .to_return(status: 503, headers: { 'Retry-After' => retry_after_datetime.rfc2822 })
         | 
| 291 | 
            +
             | 
| 292 | 
            +
                                requester = Requester.new(:get, url)
         | 
| 293 | 
            +
                                expect(requester).not_to receive(:sleep)
         | 
| 294 | 
            +
             | 
| 295 | 
            +
                                expect { requester.make_request }.to raise_error(RetryDelayTooLarge) do |ex|
         | 
| 296 | 
            +
                                  expect(ex.cause).to be_a(RestClient::ServiceUnavailable)
         | 
| 297 | 
            +
                                end
         | 
| 298 | 
            +
                              end
         | 
| 299 | 
            +
                            end
         | 
| 300 | 
            +
             | 
| 301 | 
            +
                            it 'ignores an invalid Retry-After' do
         | 
| 302 | 
            +
                              stub_request(:get, url)
         | 
| 303 | 
            +
                                .to_return(status: 503, headers: { 'Retry-After' => 'the end of the world' })
         | 
| 304 | 
            +
             | 
| 305 | 
            +
                              requester = Requester.new(:get, url)
         | 
| 306 | 
            +
                              expect { requester.make_request }.to raise_error(RestClient::ServiceUnavailable)
         | 
| 307 | 
            +
                            end
         | 
| 308 | 
            +
                          end
         | 
| 309 | 
            +
                        end
         | 
| 310 | 
            +
                      end
         | 
| 311 | 
            +
             | 
| 68 312 | 
             
                    end
         | 
| 69 313 |  | 
| 70 314 | 
             
                    describe :head do
         | 
| @@ -337,20 +337,14 @@ module BerkeleyLibrary::Util | |
| 337 337 | 
             
                    expect { URIs.path_escape(str.bytes) }.to raise_error(ArgumentError)
         | 
| 338 338 | 
             
                  end
         | 
| 339 339 |  | 
| 340 | 
            -
                  it ' | 
| 341 | 
            -
                     | 
| 342 | 
            -
                     | 
| 343 | 
            -
             | 
| 344 | 
            -
             | 
| 345 | 
            -
             | 
| 346 | 
            -
             | 
| 347 | 
            -
                     | 
| 348 | 
            -
             | 
| 349 | 
            -
                    # OK, we're really just testing String#encode here, but
         | 
| 350 | 
            -
                    # it's useful for documentation
         | 
| 351 | 
            -
                    in_str_sjis = in_str.encode(Encoding::Shift_JIS)
         | 
| 352 | 
            -
                    in_str_utf8 = in_str_sjis.encode(Encoding::UTF_8)
         | 
| 353 | 
            -
                    expect(URIs.path_escape(in_str_utf8)).to eq(out_str)
         | 
| 340 | 
            +
                  it 'converts non-UTF-8 strings to UTF-8' do
         | 
| 341 | 
            +
                    utf_16_be = Encoding.find('UTF-16BE')
         | 
| 342 | 
            +
                    aggregate_failures do
         | 
| 343 | 
            +
                      in_out.each do |in_str, out_str|
         | 
| 344 | 
            +
                        encoded = in_str.encode(utf_16_be)
         | 
| 345 | 
            +
                        expect(URIs.path_escape(encoded)).to eq(out_str)
         | 
| 346 | 
            +
                      end
         | 
| 347 | 
            +
                    end
         | 
| 354 348 | 
             
                  end
         | 
| 355 349 | 
             
                end
         | 
| 356 350 | 
             
              end
         | 
    
        metadata
    CHANGED
    
    | @@ -1,14 +1,14 @@ | |
| 1 1 | 
             
            --- !ruby/object:Gem::Specification
         | 
| 2 2 | 
             
            name: berkeley_library-util
         | 
| 3 3 | 
             
            version: !ruby/object:Gem::Version
         | 
| 4 | 
            -
              version: 0.1. | 
| 4 | 
            +
              version: 0.1.9
         | 
| 5 5 | 
             
            platform: ruby
         | 
| 6 6 | 
             
            authors:
         | 
| 7 7 | 
             
            - David Moles
         | 
| 8 8 | 
             
            autorequire:
         | 
| 9 9 | 
             
            bindir: bin
         | 
| 10 10 | 
             
            cert_chain: []
         | 
| 11 | 
            -
            date: 2023- | 
| 11 | 
            +
            date: 2023-06-01 00:00:00.000000000 Z
         | 
| 12 12 | 
             
            dependencies:
         | 
| 13 13 | 
             
            - !ruby/object:Gem::Dependency
         | 
| 14 14 | 
             
              name: berkeley_library-logging
         | 
| @@ -241,7 +241,9 @@ files: | |
| 241 241 | 
             
            - lib/berkeley_library/util/times.rb
         | 
| 242 242 | 
             
            - lib/berkeley_library/util/uris.rb
         | 
| 243 243 | 
             
            - lib/berkeley_library/util/uris/appender.rb
         | 
| 244 | 
            +
            - lib/berkeley_library/util/uris/exceptions.rb
         | 
| 244 245 | 
             
            - lib/berkeley_library/util/uris/requester.rb
         | 
| 246 | 
            +
            - lib/berkeley_library/util/uris/requester/class_methods.rb
         | 
| 245 247 | 
             
            - lib/berkeley_library/util/uris/validator.rb
         | 
| 246 248 | 
             
            - rakelib/.rubocop.yml
         | 
| 247 249 | 
             
            - rakelib/coverage.rake
         |