aspire 0.1.0

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.
Files changed (48) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +59 -0
  3. data/.rbenv-gemsets +1 -0
  4. data/.travis.yml +5 -0
  5. data/CODE_OF_CONDUCT.md +74 -0
  6. data/Dockerfile +20 -0
  7. data/Gemfile +4 -0
  8. data/LICENSE.txt +21 -0
  9. data/README.md +851 -0
  10. data/Rakefile +10 -0
  11. data/aspire.gemspec +40 -0
  12. data/bin/console +14 -0
  13. data/bin/setup +8 -0
  14. data/entrypoint.sh +11 -0
  15. data/exe/build-cache +13 -0
  16. data/lib/aspire.rb +11 -0
  17. data/lib/aspire/api.rb +2 -0
  18. data/lib/aspire/api/base.rb +198 -0
  19. data/lib/aspire/api/json.rb +195 -0
  20. data/lib/aspire/api/linked_data.rb +214 -0
  21. data/lib/aspire/caching.rb +4 -0
  22. data/lib/aspire/caching/builder.rb +356 -0
  23. data/lib/aspire/caching/cache.rb +365 -0
  24. data/lib/aspire/caching/cache_entry.rb +296 -0
  25. data/lib/aspire/caching/cache_logger.rb +63 -0
  26. data/lib/aspire/caching/util.rb +210 -0
  27. data/lib/aspire/cli/cache_builder.rb +123 -0
  28. data/lib/aspire/cli/command.rb +20 -0
  29. data/lib/aspire/enumerator/base.rb +29 -0
  30. data/lib/aspire/enumerator/json_enumerator.rb +130 -0
  31. data/lib/aspire/enumerator/linked_data_uri_enumerator.rb +32 -0
  32. data/lib/aspire/enumerator/report_enumerator.rb +64 -0
  33. data/lib/aspire/exceptions.rb +36 -0
  34. data/lib/aspire/object.rb +7 -0
  35. data/lib/aspire/object/base.rb +155 -0
  36. data/lib/aspire/object/digitisation.rb +43 -0
  37. data/lib/aspire/object/factory.rb +87 -0
  38. data/lib/aspire/object/list.rb +590 -0
  39. data/lib/aspire/object/module.rb +36 -0
  40. data/lib/aspire/object/resource.rb +371 -0
  41. data/lib/aspire/object/time_period.rb +47 -0
  42. data/lib/aspire/object/user.rb +46 -0
  43. data/lib/aspire/properties.rb +20 -0
  44. data/lib/aspire/user_lookup.rb +103 -0
  45. data/lib/aspire/util.rb +185 -0
  46. data/lib/aspire/version.rb +3 -0
  47. data/lib/retry.rb +197 -0
  48. metadata +274 -0
@@ -0,0 +1,46 @@
1
+ require 'aspire/object/base'
2
+
3
+ module Aspire
4
+ module Object
5
+ # Represents a user profile in the Aspire API
6
+ class User < Base
7
+ # @!attribute [rw] email
8
+ # @return [Array<String>] the list of email addresses for the user
9
+ attr_accessor :email
10
+
11
+ # @!attribute [rw] first_name
12
+ # @return [String] the user's first name
13
+ attr_accessor :first_name
14
+
15
+ # @!attribute [rw] role
16
+ # @return [Array<String>] the Aspire roles associated with the user
17
+ attr_accessor :role
18
+
19
+ # @!attribute [rw] surname
20
+ # @return [String] the user's last name
21
+ attr_accessor :surname
22
+
23
+ # Initialises a new User instance
24
+ # @param uri [String] the URI of the user profile
25
+ # @param factory [Aspire::Object::Factory] the data object factory
26
+ # @param json [Hash] the user profile data from the Aspire JSON API
27
+ # @param ld [Hash] the user profile data from the Aspire linked data API
28
+ # @return [void]
29
+ def initialize(uri, factory, json: nil, ld: nil)
30
+ super(uri, factory)
31
+ json ||= {}
32
+ self.email = json['email']
33
+ self.first_name = json['firstName']
34
+ self.role = json['role']
35
+ self.surname = json['surname']
36
+ end
37
+
38
+ # Returns a string representation of the user profile (name and emails)
39
+ # @return [String] the string representation of the user profile
40
+ def to_s
41
+ emails = email.nil? || email.empty? ? '' : " <#{email.join('; ')}>"
42
+ "#{first_name} #{surname}#{emails}"
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,20 @@
1
+ module Aspire
2
+ # Aspire linked data API property names
3
+ module Properties
4
+ AIISO_CODE = 'http://purl.org/vocab/aiiso/schema#code'.freeze
5
+ AIISO_NAME = 'http://putl.org/vocab/aiiso/schema#name'.freeze
6
+ CREATED = 'http://purl.org/vocab/resourcelist/schema#created'.freeze
7
+ DESCRIPTION = 'http://purl.org/vocab/resourcelist/schema#description'
8
+ .freeze
9
+ HAS_CREATOR = 'http://rdfs.org/sioc/spec/has_creator'.freeze
10
+ HAS_OWNER = 'http://purl.org/vocab/resourcelist/schema#hasOwner'.freeze
11
+ LAST_PUBLISHED = 'http://purl.org/vocab/resourcelist/schema#lastPublished'
12
+ .freeze
13
+ LAST_UPDATED = 'http://purl.org/vocab/resourcelist/schema#lastUpdated'
14
+ .freeze
15
+ NAME = 'http://rdfs.org/sioc/spec/name'.freeze
16
+ PUBLISHED_BY = 'http://purl.org/vocab/resourcelist/schema#publishedBy'
17
+ .freeze
18
+ USED_BY = 'http://purl.org/vocab/resourcelist/schema#usedBy'.freeze
19
+ end
20
+ end
@@ -0,0 +1,103 @@
1
+ require 'csv'
2
+
3
+ require 'aspire/enumerator/report_enumerator'
4
+ require 'aspire/object/user'
5
+
6
+ module Aspire
7
+ # Implements a hash of User instances indexed by URI
8
+ # The hash can be populated from an Aspire All User Profiles report CSV file
9
+ class UserLookup
10
+ # @!attribute [rw] store
11
+ # @return [Object] a hash-like object mapping user URIs to their JSON data
12
+ attr_accessor :store
13
+
14
+ # Initialises a new UserLookup instance
15
+ # @see (Hash#initialize)
16
+ # @param filename [String] the filename of the CSV file to populate the hash
17
+ # @return [void]
18
+ def initialize(filename: nil, store: nil)
19
+ self.store = store || {}
20
+ load(filename) if filename
21
+ end
22
+
23
+ # Returns an Aspire::Object::User instance for a URI
24
+ # @param uri [String] the URI of the user
25
+ # @param factory [Aspire::Object::Factory] the data object factory
26
+ # @return [Aspire::Object::User] the user
27
+ def [](uri, factory = nil)
28
+ data = store[uri]
29
+ data.nil? ? nil : Aspire::Object::User.new(uri, factory, json: data)
30
+ end
31
+
32
+ # Populates the store from an All User Profiles report CSV file
33
+ # @param filename [String] the filename of the CSV file
34
+ # @return [void]
35
+ def load(filename = nil)
36
+ delim = /\s*;\s*/ # The delimiter for email and role lists
37
+ enum = Aspire::Enumerator::ReportEnumerator.new(filename).enumerator
38
+ enum.each do |row|
39
+ # Construct a JSON data structure for the user
40
+ uri = row[3]
41
+ data = csv_to_json_api(row, email_delim: delim, role_delim: delim)
42
+ csv_to_json_other(row, data)
43
+ # Store the JSON data in the lookup table
44
+ store[uri] = data
45
+ end
46
+ end
47
+
48
+ # Proxies missing methods to the store
49
+ # @param method [Symbol] the method name
50
+ # @param args [Array] the method arguments
51
+ # @param block [Proc] the code block
52
+ # @return [Object] the store method result
53
+ def method_missing(method, *args, &block)
54
+ super unless store.respond_to?(method)
55
+ store.public_send(method, *args, &block)
56
+ end
57
+
58
+ # Proxies missing method respond_to? to the store
59
+ # @param method [Symbol] the method name
60
+ # @param include_private [Boolean] if true, include private methods,
61
+ # otherwise include only public methods
62
+ # @return [Boolean] true if the store supports the method, false otherwise
63
+ def respond_to_missing?(method, include_private = false)
64
+ store.respond_to?(method, include_private)
65
+ end
66
+
67
+ private
68
+
69
+ def csv_to_json(row)
70
+ # Recreate the Aspire user profile JSON API response from the CSV record
71
+ data = csv_to_json_api(row)
72
+ # Add other report fields which aren't part of the JSON API response
73
+ csv_to_json_other(row, data)
74
+ end
75
+
76
+ # Adds CSV fields which mirror the Aspire user profile JSON API fields
77
+ # @param row [Array] the fields from the All User Profiles report CSV
78
+ # @param data [Hash] the JSON representation of the user profile
79
+ # @return [Hash] the JSON data hash
80
+ def csv_to_json_api(row, data = {}, email_delim: nil, role_delim: nil)
81
+ data['email'] = (row[4] || '').split(email_delim)
82
+ data['firstName'] = row[0]
83
+ data['role'] = (row[7] || '').split(role_delim)
84
+ data['surname'] = row[1]
85
+ data['uri'] = row[3]
86
+ data
87
+ end
88
+
89
+ # Adds CSV fields which aren't part of the Aspire user profile JSON API
90
+ # @param row [Array] the fields from the All User Profiles report CSV
91
+ # @param data [Hash] the JSON representation of the user profile
92
+ # @return [Hash] the JSON data hash
93
+ def csv_to_json_other(row, data = {})
94
+ # The following fields are not present in the JSON API response but are in
95
+ # the All User Profiles report - they are included for completeness.
96
+ data['jobRole'] = row[5] || ''
97
+ data['lastLogin'] = row[8]
98
+ data['name'] = row[2] || ''
99
+ data['visibility'] = row[6] || ''
100
+ data
101
+ end
102
+ end
103
+ end
@@ -0,0 +1,185 @@
1
+ module Aspire
2
+ # Utility methods mixin
3
+ module Util
4
+ # Regular expression to parse a Linked Data API URI
5
+ LD_API_URI = Regexp.new('https?://(?<tenancy_host>[^/]*)/' \
6
+ '(?<type>[^/]*)/' \
7
+ '(?<id>[^/\.]*)' \
8
+ '(\.(?<format>[^/]*))?' \
9
+ '(/' \
10
+ '(?<child_type>[^/.]*)' \
11
+ '(/(?<child_id>[^/\.]*))?' \
12
+ '(\.(?<child_format>[^/]*))?' \
13
+ ')?(?<rest>.*)').freeze
14
+
15
+ # Returns true if the first URL is the child of the second URL
16
+ # @param url1 [Aspire::Caching::CacheEntry, String] the first URL
17
+ # @param url2 [Aspire::Caching::CacheEntry, String] the second URL
18
+ # @param api [Aspire::API::LinkedData] the API for generating canonical URLs
19
+ # @param strict [Boolean] if true, the URL must be a parent of this entry,
20
+ # otherwise the URL must be a parent or the same as this entry
21
+ # @return [Boolean] true if the URL is a child of the cache entry, false
22
+ # otherwise
23
+ def child_url?(url1, url2, api = nil, strict: false)
24
+ parent_url?(url2, url1, api, strict: strict)
25
+ end
26
+
27
+ # Returns a HH:MM:SS string given a Benchmark time
28
+ # @param benchmark_time [Benchmark:Tms] the Benchmark time object
29
+ # @return [String] the HH:HM:SS string
30
+ def duration(benchmark_time)
31
+ secs = benchmark_time.real
32
+ hours = secs / 3600
33
+ format('%2.2d:%2.2d:%2.2d', hours, hours % 60, secs % 60)
34
+ end
35
+
36
+ # Returns the ID of an object from its URL
37
+ # @param u [String] the URL of the API object
38
+ # @return [String] the object ID
39
+ def id_from_uri(u, parsed: nil)
40
+ parsed ||= parse_url(u)
41
+ parsed[:id]
42
+ end
43
+
44
+ # Returns true if URI is a list item
45
+ # @param uri [String] the URI
46
+ # @return [Boolean] true if the URI is a list item, otherwise false
47
+ def item?(uri)
48
+ uri.include?('/items/')
49
+ end
50
+
51
+ # Returns the data for a URI from a parsed linked data API response
52
+ # which may contain multiple objects
53
+ # @param uri [String] the URI of the object
54
+ # @param ld [Hash] the parsed JSON data from the Aspire linked data API
55
+ # @return [Hash] the parsed JSON data for the URI
56
+ def linked_data(uri, ld)
57
+ uri = linked_data_path(uri)
58
+ return nil unless uri && ld
59
+ # The URI used to retrieve the data may be the canonical URI or a
60
+ # tenancy aliases. We ignore the host part of the URIs and match just
61
+ # the path
62
+ ld.each { |u, data| return data if uri == linked_data_path(u) }
63
+ # No match was found
64
+ nil
65
+ end
66
+
67
+ # Returns the path of a URI
68
+ # @param uri [String] the URI
69
+ # @return [String, nil] the URI path or nil if invalid
70
+ def linked_data_path(uri)
71
+ URI.parse(uri).path
72
+ rescue URI::InvalidComponentError, URI::InvalidURIError
73
+ nil
74
+ end
75
+
76
+ # Returns true if URI is a list
77
+ # @param uri [String] the URI
78
+ # @return [Boolean] true if the URI is a list, otherwise false
79
+ def list?(uri)
80
+ uri.include?('/lists/')
81
+ end
82
+
83
+ # Returns true if a URL is a list URL, false otherwise
84
+ # @param u [String] the URL of the API object
85
+ # @return [Boolean] true if the URL is a list URL, false otherwise
86
+ def list_url?(u = nil, parsed: nil)
87
+ return false if (u.nil? || u.empty?) && parsed.nil?
88
+ parsed ||= parse_url(u)
89
+ child_type = parsed[:child_type]
90
+ parsed[:type] == 'lists' && (child_type.nil? || child_type.empty?)
91
+ end
92
+
93
+ # Returns true if URI is a module
94
+ # @param uri [String] the URI
95
+ # @return [Boolean] true if the URI is a module, otherwise false
96
+ def module?(uri)
97
+ uri.include?('/modules/')
98
+ end
99
+
100
+ # Returns true if the first URL is the parent of the second URL
101
+ # @param url1 [Aspire::Caching::CacheEntry, String] the first URL
102
+ # @param url2 [Aspire::Caching::CacheEntry, String] the second URL
103
+ # @param api [Aspire::API::LinkedData] the API for generating canonical URLs
104
+ # @param strict [Boolean] if true, the first URL must be a parent of the
105
+ # second URL, otherwise the first URL must be a parent or the same as the
106
+ # second.
107
+ # @return [Boolean] true if the URL has the same parent as this entry
108
+ def parent_url?(url1, url2, api = nil, strict: false)
109
+ u1 = url_for_comparison(url1, api, parsed: true)
110
+ u2 = url_for_comparison(url2, api, parsed: true)
111
+ # Both URLs must have the same parent
112
+ return false unless u1[:type] == u2[:type] && u1[:id] == u2[:id]
113
+ # Non-strict comparison requires only the same parent object
114
+ return true unless strict
115
+ # Strict comparison requires that this entry is a child of the URL
116
+ u1[:child_type].nil? && !u2[:child_type].nil? ? true : false
117
+ end
118
+
119
+ # Returns the components of an object URL
120
+ # @param url [String] the object URL
121
+ # @return [MatchData, nil] the URI components:
122
+ # {
123
+ # tenancy_host: tenancy root (server name),
124
+ # type: type of primary object,
125
+ # id: ID of primary object,
126
+ # child_type: type of child object,
127
+ # child_id: ID of child object
128
+ # }
129
+ def parse_url(url)
130
+ url ? LD_API_URI.match(url) : nil
131
+ end
132
+
133
+ # Returns true if URI is a resource
134
+ # @param uri [String] the URI
135
+ # @return [Boolean] true if the URI is a resource, otherwise false
136
+ def resource?(uri)
137
+ uri.include?('/resources/')
138
+ end
139
+
140
+ # Returns true if URI is a section
141
+ # @param uri [String] the URI
142
+ # @return [Boolean] true if the URI is a section, otherwise false
143
+ def section?(uri)
144
+ uri.include?('/sections/')
145
+ end
146
+
147
+ # Returns a parsed or unparsed URL for comparison
148
+ # @param url [Aspire::Caching::CacheEntry, String] the URL
149
+ # @param api [Aspire::API::LinkedData] the API for generating canonical URLs
150
+ # @param parsed [Boolean] if true, return a parsed URL, otherwise return
151
+ # an unparsed URL string
152
+ # @return [Aspire::Caching::CacheEntry, String] the URL for comparison
153
+ def url_for_comparison(url, api = nil, parsed: false)
154
+ if url.is_a?(MatchData) && parsed
155
+ url
156
+ elsif parsed && url.respond_to?(:parsed_url)
157
+ url.parsed_url
158
+ elsif !parsed && url.respond_to?(url)
159
+ url.url
160
+ else
161
+ result = api.nil? ? url.to_s : api.canonical_url(url.to_s)
162
+ parsed ? parse_url(result) : result
163
+ end
164
+ end
165
+
166
+ # Returns the path from the URL as a relative filename
167
+ def url_path
168
+ # Get the path component of the URL as a relative path
169
+ filename = URI.parse(url).path
170
+ filename.slice!(0) # Remove the leading /
171
+ # Return the path with '.json' extension if not already present
172
+ filename.end_with?('.json') ? filename : "#{filename}.json"
173
+ rescue URI::InvalidComponentError, URI::InvalidURIError
174
+ # Return nil if the URL is invalid
175
+ nil
176
+ end
177
+
178
+ # Returns true if URI is a section
179
+ # @param uri [String] the URI
180
+ # @return [Boolean] true if the URI is a section, otherwise false
181
+ def user?(uri)
182
+ uri.include?('/users/')
183
+ end
184
+ end
185
+ end
@@ -0,0 +1,3 @@
1
+ module Aspire
2
+ VERSION = '0.1.0'.freeze
3
+ end
@@ -0,0 +1,197 @@
1
+ # Support for performing retriable operations
2
+ module Retry
3
+ # Common exceptions suitable for retrying
4
+ module Exceptions
5
+ SOCKET_EXCEPTIONS = [
6
+ Errno::ECONNREFUSED,
7
+ Errno::ECONNRESET,
8
+ Errno::EINTR,
9
+ Errno::EHOSTUNREACH,
10
+ Errno::ENETDOWN,
11
+ Errno::ENETUNREACH,
12
+ Errno::ENOBUFS,
13
+ Errno::ENOSR,
14
+ Errno::ETIMEDOUT,
15
+ IO::WaitReadable
16
+ ].freeze
17
+ end
18
+
19
+ # Retry handlers should raise this exception to stop retry processing and
20
+ # return the return value from the Retry.do method
21
+ class StopRetry < RuntimeError
22
+ attr_accessor :value
23
+ def initialize(value = nil)
24
+ self.value = value
25
+ end
26
+ end
27
+
28
+ # Support for repeatedly calling retriable operations
29
+ class Engine
30
+ attr_accessor :delay
31
+ attr_accessor :exceptions
32
+ attr_accessor :handlers
33
+ attr_accessor :tries
34
+
35
+ # Initialises a new Engine instance
36
+ # @param delay [Float] the default delay before retrying
37
+ # @param exceptions [Hash<Exception, Boolean>] the default retriable
38
+ # exceptions
39
+ # @param handlers [Hash<Exception|Symbol, Proc>] the default exception
40
+ # handlers
41
+ # @param tries [Integer, Proc] the default maximum number of tries or
42
+ # a proc which accepts an Exception and returns true if a retry is allowed
43
+ # or false if not
44
+ # @return [void]
45
+ def initialize(delay: nil, exceptions: nil, handlers: nil, tries: nil)
46
+ self.delay = delay.to_f
47
+ self.exceptions = exceptions || {}
48
+ self.handlers = handlers || {}
49
+ self.tries = tries
50
+ end
51
+
52
+ # Executes the class method do using instance default values
53
+ def do(delay: nil, exceptions: nil, handlers: nil, tries: nil, &block)
54
+ Retry.do(delay: delay || self.delay,
55
+ exceptions: exceptions || self.exceptions,
56
+ handlers: handlers || self.handlers,
57
+ tries: tries || self.tries,
58
+ &block)
59
+ end
60
+ end
61
+
62
+ # Executes the code block until it returns successfully, throws a
63
+ # non-retriable exception or some termination condition is met.
64
+ # @param delay [Float] the number of seconds to wait before retrying.
65
+ # Positive values specify an exact delay, negative values specify a
66
+ # random delay no longer than this value.
67
+ # @param exceptions [Hash<Exception, Boolean>] the hash of retriable
68
+ # exceptions
69
+ # @param handlers [Hash<Exception|Symbol, Proc>] handlers to be invoked
70
+ # when specific exceptions occur. A handler should accept the exception
71
+ # and the number of tries remaining as arguments. It does not need to
72
+ # re-raise its exception argument, but it may throw another exception
73
+ # to prevent a retry.
74
+ # @param tries [Integer] the maximum number of tries
75
+ # @return [Object] the return value of the block
76
+ def self.do(delay: nil, exceptions: nil, handlers: nil, tries: nil)
77
+ yield if block_given?
78
+ rescue StandardError => exception
79
+ # Decrement the tries-remaining count if appropriate
80
+ tries -= 1 if tries.is_a?(Numeric)
81
+ # Handlers may raise StopRetry to force a return value from the method
82
+ # Check if the exception is retriable
83
+ retriable = retry?(exception, exceptions, tries)
84
+ begin
85
+ # Run the exception handler
86
+ # - this will re-raise the exception if it is not retriable
87
+ handle_exception(exception, handlers, tries, retriable)
88
+ # Run the retry handler and retry
89
+ handle_retry(exception, handlers, tries, retriable, delay)
90
+ retry
91
+ rescue StopRetry => exception
92
+ # Force a return value from the handler
93
+ exception.value
94
+ end
95
+ end
96
+
97
+ # Executes a handler for an exception
98
+ # @param exception [Exception] the exception
99
+ # @param handlers [Hash<Exception|Symbol, Proc>] the exception handlers
100
+ # @param tries [Integer] the number of tries remaining
101
+ # @param retriable [Boolean] true if the exception is retriable, false if not
102
+ # @return [Object] the return value of the handler, or nil if no handler
103
+ # was executed
104
+ def self.handle_exception(exception, handlers, tries, retriable)
105
+ # Execute the general exception handler
106
+ handler(exception, handlers, tries, retriable, :all)
107
+ # Execute the exception-specific handler
108
+ handler(exception, handlers, tries, retriable)
109
+ # Re-raise the exception if not retriable
110
+ raise exception unless retriable
111
+ end
112
+
113
+ # Executes the retry handler
114
+ # @param exception [Exception] the exception
115
+ # @param handlers [Hash<Exception|Symbol, Proc>] the exception handlers
116
+ # @param tries [Integer] the number of tries remaining
117
+ # @param retriable [Boolean] true if the exception is retriable, false if not
118
+ # @param delay [Float] the number of seconds to wait before retrying
119
+ def self.handle_retry(exception, handlers, tries, retriable, delay)
120
+ # Wait for the specified delay
121
+ wait(delay) unless delay.zero?
122
+ # Return the result of the retry handler
123
+ handler(exception, handlers, tries, retriable, :retry)
124
+ end
125
+
126
+ # Executes the specified handler
127
+ # @param exception [Exception] the exception
128
+ # @param handlers [Hash<Exception|Symbol, Proc>] the exception handlers
129
+ # @param tries [Integer] the number of tries remaining
130
+ # @param retriable [Boolean] true if the exception is retriable, false if not
131
+ # @param name [Symbol] the handler name, defaults to the exception class
132
+ # @return [Object] the return value of the handler, or nil if no handler
133
+ # was executed
134
+ def self.handler(exception, handlers, tries, retriable, name = nil)
135
+ handler = nil
136
+ if name.nil?
137
+ # Find the handler for the exception class
138
+ handlers.each do |e, h|
139
+ next unless e.is_a?(Class) && exception.is_a?(e)
140
+ handler = h
141
+ break
142
+ end
143
+ # Use the default handler if no match was found
144
+ handler ||= handlers[:default]
145
+ else
146
+ # Use the named handler, do not use the default if not found
147
+ handler = handlers[name]
148
+ end
149
+ handler ? handler.call(exception, tries, retriable) : nil
150
+ end
151
+
152
+ # Returns true if the exception instance is retriable, false if not
153
+ # @param exception [Exception] the exception instance
154
+ # @param tries [Integer, Proc] the number of tries remaining or a proc
155
+ # determining whether tries remain
156
+ # @return [Boolean] true if the exception is retriable, false if not
157
+ def self.retry?(exception, exceptions, tries)
158
+ # Return false if there are no more tries remaining
159
+ return false unless tries_remain?(exception, tries)
160
+ # Return true if the exception matches a retriable exception class
161
+ exceptions.each { |e| return true if exception.is_a?(e) }
162
+ # The exception didn't match any retriable classes
163
+ false
164
+ end
165
+
166
+ # Returns true if there are tries remaining
167
+ # @param exception [Exception] the exception instance
168
+ # @param tries [Integer, Proc] the number of tries remaining or a proc
169
+ # determining whether tries remain
170
+ def self.tries_remain?(exception, tries)
171
+ # If tries is numeric, this is the number of tries remaining
172
+ return false if tries.is_a?(Numeric) && tries.zero?
173
+ # If tries has a #call method, this should return true to allow a retry or
174
+ # false to raise the exception
175
+ return false if tries.respond_to?(:call) && !tries.call(exception)
176
+ # Otherwise allow a retry
177
+ true
178
+ end
179
+
180
+ # Waits for the specified number of seconds. If delay is positive, sleep
181
+ # for that period. If delay is negative, sleep for a random time up to
182
+ # that duration.
183
+ # @param delay [Float] the number of seconds to wait before retrying
184
+ # @return [void]
185
+ def self.wait(delay)
186
+ sleep(delay > 0 ? delay : Random.rand(-delay))
187
+ end
188
+
189
+ class << self
190
+ private :handle_exception
191
+ private :handle_retry
192
+ private :handler
193
+ private :retry?
194
+ private :tries_remain?
195
+ private :wait
196
+ end
197
+ end