contractful 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.
- checksums.yaml +7 -0
- data/API.md +142 -0
- data/ARCHITECTURE.md +134 -0
- data/AUTHORS +106 -0
- data/CODE_OF_CONDUCT.md +128 -0
- data/LICENSE +21 -0
- data/README.md +1290 -0
- data/SECURITY.md +67 -0
- data/bin/contractful +128 -0
- data/history.md +801 -0
- data/lib/contractful/abstract_response.rb +301 -0
- data/lib/contractful/circuit_breaker.rb +221 -0
- data/lib/contractful/connection_pool.rb +166 -0
- data/lib/contractful/contract/auth.rb +257 -0
- data/lib/contractful/contract/config.rb +93 -0
- data/lib/contractful/contract/definition.rb +295 -0
- data/lib/contractful/contract/dsl.rb +501 -0
- data/lib/contractful/contract/executor.rb +492 -0
- data/lib/contractful/contract/openapi_import.rb +1135 -0
- data/lib/contractful/contract/pagination.rb +218 -0
- data/lib/contractful/contract/param.rb +505 -0
- data/lib/contractful/contract/postman_import/auth.rb +39 -0
- data/lib/contractful/contract/postman_import/body.rb +87 -0
- data/lib/contractful/contract/postman_import/operation.rb +107 -0
- data/lib/contractful/contract/postman_import/scope.rb +37 -0
- data/lib/contractful/contract/postman_import/url.rb +80 -0
- data/lib/contractful/contract/postman_import.rb +164 -0
- data/lib/contractful/contract/response_field.rb +167 -0
- data/lib/contractful/contract/sequence.rb +432 -0
- data/lib/contractful/contract/swagger2/components.rb +84 -0
- data/lib/contractful/contract/swagger2/operations.rb +81 -0
- data/lib/contractful/contract/swagger2/parameters.rb +93 -0
- data/lib/contractful/contract/swagger2/references.rb +55 -0
- data/lib/contractful/contract/swagger2.rb +55 -0
- data/lib/contractful/contract.rb +85 -0
- data/lib/contractful/exceptions.rb +328 -0
- data/lib/contractful/notifications/active_support.rb +40 -0
- data/lib/contractful/notifications.rb +138 -0
- data/lib/contractful/pacer.rb +161 -0
- data/lib/contractful/params_array.rb +85 -0
- data/lib/contractful/payload.rb +340 -0
- data/lib/contractful/platform.rb +52 -0
- data/lib/contractful/rate_limit.rb +133 -0
- data/lib/contractful/raw_response.rb +70 -0
- data/lib/contractful/request.rb +1483 -0
- data/lib/contractful/resource.rb +189 -0
- data/lib/contractful/response.rb +109 -0
- data/lib/contractful/utils.rb +202 -0
- data/lib/contractful/version.rb +11 -0
- data/lib/contractful/windows/root_certs.rb +107 -0
- data/lib/contractful/windows.rb +9 -0
- data/lib/contractful.rb +233 -0
- metadata +244 -0
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Contractful
|
|
4
|
+
module Contract
|
|
5
|
+
module Swagger2
|
|
6
|
+
# Swagger 2.0 keeps definitions, parameters and responses in top-level
|
|
7
|
+
# sections; OpenAPI 3.0 keeps them under components. Local $refs follow.
|
|
8
|
+
module References
|
|
9
|
+
SECTIONS = { 'definitions' => 'schemas', 'parameters' => 'parameters', 'responses' => 'responses' }.freeze
|
|
10
|
+
|
|
11
|
+
module_function
|
|
12
|
+
|
|
13
|
+
# Copy a document with its local $refs translated. Memoized by identity,
|
|
14
|
+
# so YAML aliases and cycles copy once and terminate.
|
|
15
|
+
def translate(node, memo = {}.compare_by_identity)
|
|
16
|
+
case node
|
|
17
|
+
when Hash then translate_hash(node, memo)
|
|
18
|
+
when Array then translate_array(node, memo)
|
|
19
|
+
else node
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def translate_hash(node, memo)
|
|
24
|
+
return memo[node] if memo.key?(node)
|
|
25
|
+
|
|
26
|
+
out = memo[node] = {}
|
|
27
|
+
node.each do |key, value|
|
|
28
|
+
out[key] = key == '$ref' && value.is_a?(String) ? translate_ref(value) : translate(value, memo)
|
|
29
|
+
end
|
|
30
|
+
out
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def translate_array(node, memo)
|
|
34
|
+
return memo[node] if memo.key?(node)
|
|
35
|
+
|
|
36
|
+
out = memo[node] = []
|
|
37
|
+
node.each { |value| out << translate(value, memo) }
|
|
38
|
+
out
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def translate_ref(ref)
|
|
42
|
+
ref.start_with?('#') ? "##{translate_pointer(ref[1..])}" : ref
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def translate_pointer(fragment)
|
|
46
|
+
return nil if fragment.nil?
|
|
47
|
+
|
|
48
|
+
fragment.sub(%r{\A/(definitions|parameters|responses)(?=/|\z)}) do
|
|
49
|
+
"/components/#{SECTIONS.fetch(Regexp.last_match(1))}"
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'swagger2/references'
|
|
4
|
+
require_relative 'swagger2/parameters'
|
|
5
|
+
require_relative 'swagger2/operations'
|
|
6
|
+
require_relative 'swagger2/components'
|
|
7
|
+
|
|
8
|
+
module Contractful
|
|
9
|
+
module Contract
|
|
10
|
+
# Converts a Swagger 2.0 document into the OpenAPI 3.0 shape that
|
|
11
|
+
# {OpenAPIImport} reads. The conversion is structural and covers the
|
|
12
|
+
# constructs the importer maps: paths and operations, parameters (path,
|
|
13
|
+
# query, header, body and formData), responses, definitions and security.
|
|
14
|
+
# Everything else is copied through unchanged.
|
|
15
|
+
#
|
|
16
|
+
# Opt-in, like {OpenAPIImport}: it is loaded by
|
|
17
|
+
# <tt>require 'contractful/contract/openapi_import'</tt>.
|
|
18
|
+
module Swagger2
|
|
19
|
+
module_function
|
|
20
|
+
|
|
21
|
+
# @return [Boolean] true for a Hash that declares a Swagger 2.x version
|
|
22
|
+
def swagger?(doc)
|
|
23
|
+
doc.is_a?(Hash) && doc['swagger'].to_s.start_with?('2.')
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Convert a Swagger 2.0 document (a Hash) into an OpenAPI 3.0.3 Hash. The
|
|
27
|
+
# input is not modified.
|
|
28
|
+
def convert(doc)
|
|
29
|
+
root = References.translate(doc)
|
|
30
|
+
{ 'openapi' => '3.0.3', 'x-contractful-source' => 'Swagger 2.0' }
|
|
31
|
+
.merge(document_fields(root))
|
|
32
|
+
.merge(root.select { |key, _| key.to_s.start_with?('x-') })
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def document_fields(root)
|
|
36
|
+
fields = { 'servers' => Components.servers(root), 'paths' => Operations.convert_paths(root) }
|
|
37
|
+
fields['info'] = root['info'] if root['info']
|
|
38
|
+
fields['security'] = root['security'] if root['security']
|
|
39
|
+
components = Components.components(root)
|
|
40
|
+
fields['components'] = components unless components.empty?
|
|
41
|
+
fields
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Map a Swagger 2.0 $ref fragment to its OpenAPI 3.0 location, for refs
|
|
45
|
+
# from other files into a converted document.
|
|
46
|
+
def translate_pointer(fragment)
|
|
47
|
+
References.translate_pointer(fragment)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def list(value)
|
|
51
|
+
value.is_a?(Array) ? value : []
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'securerandom'
|
|
4
|
+
|
|
5
|
+
module Contractful
|
|
6
|
+
# Contract-first architecture for contractful.
|
|
7
|
+
#
|
|
8
|
+
# Instead of hand-writing request code, you declare a *contract* — the HTTP
|
|
9
|
+
# shape of an API (verb, path, typed parameters, expected responses) — and
|
|
10
|
+
# metaprogramming generates the client methods, path builders, validation
|
|
11
|
+
# and introspection for you:
|
|
12
|
+
#
|
|
13
|
+
# class GitHubAPI
|
|
14
|
+
# include Contractful::Contract
|
|
15
|
+
#
|
|
16
|
+
# base 'https://api.github.com'
|
|
17
|
+
# default_headers 'Accept' => 'application/vnd.github+json'
|
|
18
|
+
#
|
|
19
|
+
# contract :list_users, 'GET /users' do
|
|
20
|
+
# param :page, Integer, default: 1
|
|
21
|
+
# param :per_page, Integer, inclusion: (1..100), default: 30
|
|
22
|
+
# end
|
|
23
|
+
#
|
|
24
|
+
# contract :create_issue, 'POST /repos/:owner/:repo/issues' do
|
|
25
|
+
# param :owner, String, required: true
|
|
26
|
+
# param :repo, String, required: true
|
|
27
|
+
# param :title, String, required: true
|
|
28
|
+
# end
|
|
29
|
+
# end
|
|
30
|
+
#
|
|
31
|
+
# api = GitHubAPI.new
|
|
32
|
+
# api.list_users(page: 2)
|
|
33
|
+
# api.create_issue(owner: 'octo', repo: 'hello', title: 'Hi')
|
|
34
|
+
#
|
|
35
|
+
# The contract *is* the implementation: nothing about the request is
|
|
36
|
+
# written imperatively. Violations raise precise errors from the
|
|
37
|
+
# {Contract::Error} hierarchy instead of producing malformed requests.
|
|
38
|
+
module Contract
|
|
39
|
+
# Root of all contract errors (definition mistakes, param violations,
|
|
40
|
+
# response mismatches, misconfiguration).
|
|
41
|
+
class Error < StandardError; end
|
|
42
|
+
|
|
43
|
+
# Raised at definition time when a contract, param or macro is invalid.
|
|
44
|
+
class InvalidDefinition < Error; end
|
|
45
|
+
|
|
46
|
+
# Raised at execution time when no base URL is configured.
|
|
47
|
+
class MissingBaseUrl < Error; end
|
|
48
|
+
|
|
49
|
+
# Base class for parameter violations (missing, unknown, invalid).
|
|
50
|
+
class ParamError < Error; end
|
|
51
|
+
|
|
52
|
+
# A required param was not provided.
|
|
53
|
+
class MissingParam < ParamError; end
|
|
54
|
+
|
|
55
|
+
# A param was provided that the contract does not declare (strict mode).
|
|
56
|
+
class UnknownParam < ParamError; end
|
|
57
|
+
|
|
58
|
+
# A param value could not be coerced/validated against its declared type.
|
|
59
|
+
class InvalidParam < ParamError; end
|
|
60
|
+
|
|
61
|
+
# The HTTP response did not satisfy the contract's response declaration
|
|
62
|
+
# (unexpected status code, body type mismatch or failed verify_response
|
|
63
|
+
# block). The offending +response+ is attached for inspection.
|
|
64
|
+
class UnexpectedResponse < Error
|
|
65
|
+
attr_reader :response
|
|
66
|
+
|
|
67
|
+
def initialize(message = nil, response: nil)
|
|
68
|
+
@response = response
|
|
69
|
+
super(message)
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Sentinel for "no default value declared" (distinct from `default: nil`).
|
|
74
|
+
NO_DEFAULT = Object.new.freeze
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
require_relative 'contract/param'
|
|
79
|
+
require_relative 'contract/response_field'
|
|
80
|
+
require_relative 'contract/auth'
|
|
81
|
+
require_relative 'contract/pagination'
|
|
82
|
+
require_relative 'contract/definition'
|
|
83
|
+
require_relative 'contract/config'
|
|
84
|
+
require_relative 'contract/executor'
|
|
85
|
+
require_relative 'contract/dsl'
|
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Root namespace: exceptions raised for non-2xx responses and request
|
|
4
|
+
# failures, each carrying the originating response when one exists.
|
|
5
|
+
module Contractful
|
|
6
|
+
# Hash of HTTP status code => message.
|
|
7
|
+
#
|
|
8
|
+
# 1xx: Informational - Request received, continuing process
|
|
9
|
+
# 2xx: Success - The action was successfully received, understood, and
|
|
10
|
+
# accepted
|
|
11
|
+
# 3xx: Redirection - Further action must be taken in order to complete the
|
|
12
|
+
# request
|
|
13
|
+
# 4xx: Client Error - The request contains bad syntax or cannot be fulfilled
|
|
14
|
+
# 5xx: Server Error - The server failed to fulfill an apparently valid
|
|
15
|
+
# request
|
|
16
|
+
#
|
|
17
|
+
# @see
|
|
18
|
+
# http://www.iana.org/assignments/http-status-codes/http-status-codes.xhtml
|
|
19
|
+
#
|
|
20
|
+
STATUSES = { 100 => 'Continue',
|
|
21
|
+
101 => 'Switching Protocols',
|
|
22
|
+
102 => 'Processing', # WebDAV
|
|
23
|
+
|
|
24
|
+
200 => 'OK',
|
|
25
|
+
201 => 'Created',
|
|
26
|
+
202 => 'Accepted',
|
|
27
|
+
203 => 'Non-Authoritative Information', # http/1.1
|
|
28
|
+
204 => 'No Content',
|
|
29
|
+
205 => 'Reset Content',
|
|
30
|
+
206 => 'Partial Content',
|
|
31
|
+
207 => 'Multi-Status', # WebDAV
|
|
32
|
+
208 => 'Already Reported', # RFC5842
|
|
33
|
+
226 => 'IM Used', # RFC3229
|
|
34
|
+
|
|
35
|
+
300 => 'Multiple Choices',
|
|
36
|
+
301 => 'Moved Permanently',
|
|
37
|
+
302 => 'Found',
|
|
38
|
+
303 => 'See Other', # http/1.1
|
|
39
|
+
304 => 'Not Modified',
|
|
40
|
+
305 => 'Use Proxy', # http/1.1
|
|
41
|
+
306 => 'Switch Proxy', # no longer used
|
|
42
|
+
307 => 'Temporary Redirect', # http/1.1
|
|
43
|
+
308 => 'Permanent Redirect', # RFC7538
|
|
44
|
+
|
|
45
|
+
400 => 'Bad Request',
|
|
46
|
+
401 => 'Unauthorized',
|
|
47
|
+
402 => 'Payment Required',
|
|
48
|
+
403 => 'Forbidden',
|
|
49
|
+
404 => 'Not Found',
|
|
50
|
+
405 => 'Method Not Allowed',
|
|
51
|
+
406 => 'Not Acceptable',
|
|
52
|
+
407 => 'Proxy Authentication Required',
|
|
53
|
+
408 => 'Request Timeout',
|
|
54
|
+
409 => 'Conflict',
|
|
55
|
+
410 => 'Gone',
|
|
56
|
+
411 => 'Length Required',
|
|
57
|
+
412 => 'Precondition Failed',
|
|
58
|
+
413 => 'Payload Too Large', # RFC7231 (renamed, see below)
|
|
59
|
+
414 => 'URI Too Long', # RFC7231 (renamed, see below)
|
|
60
|
+
415 => 'Unsupported Media Type',
|
|
61
|
+
416 => 'Range Not Satisfiable', # RFC7233 (renamed, see below)
|
|
62
|
+
417 => 'Expectation Failed',
|
|
63
|
+
418 => 'I\'m A Teapot', # RFC2324
|
|
64
|
+
421 => 'Too Many Connections From This IP',
|
|
65
|
+
422 => 'Unprocessable Entity', # WebDAV
|
|
66
|
+
423 => 'Locked', # WebDAV
|
|
67
|
+
424 => 'Failed Dependency', # WebDAV
|
|
68
|
+
425 => 'Unordered Collection', # WebDAV
|
|
69
|
+
426 => 'Upgrade Required',
|
|
70
|
+
428 => 'Precondition Required', # RFC6585
|
|
71
|
+
429 => 'Too Many Requests', # RFC6585
|
|
72
|
+
431 => 'Request Header Fields Too Large', # RFC6585
|
|
73
|
+
449 => 'Retry With', # Microsoft
|
|
74
|
+
450 => 'Blocked By Windows Parental Controls', # Microsoft
|
|
75
|
+
|
|
76
|
+
500 => 'Internal Server Error',
|
|
77
|
+
501 => 'Not Implemented',
|
|
78
|
+
502 => 'Bad Gateway',
|
|
79
|
+
503 => 'Service Unavailable',
|
|
80
|
+
504 => 'Gateway Timeout',
|
|
81
|
+
505 => 'HTTP Version Not Supported',
|
|
82
|
+
506 => 'Variant Also Negotiates',
|
|
83
|
+
507 => 'Insufficient Storage', # WebDAV
|
|
84
|
+
508 => 'Loop Detected', # RFC5842
|
|
85
|
+
509 => 'Bandwidth Limit Exceeded', # Apache
|
|
86
|
+
510 => 'Not Extended',
|
|
87
|
+
511 => 'Network Authentication Required' } # RFC6585
|
|
88
|
+
|
|
89
|
+
STATUSES_COMPATIBILITY = {
|
|
90
|
+
# The RFCs all specify "Not Found", but "Resource Not Found" was used in
|
|
91
|
+
# earlier Contractful releases.
|
|
92
|
+
404 => ['ResourceNotFound'],
|
|
93
|
+
|
|
94
|
+
# HTTP 413 was renamed to "Payload Too Large" in RFC7231.
|
|
95
|
+
413 => ['RequestEntityTooLarge'],
|
|
96
|
+
|
|
97
|
+
# HTTP 414 was renamed to "URI Too Long" in RFC7231.
|
|
98
|
+
414 => ['RequestURITooLong'],
|
|
99
|
+
|
|
100
|
+
# HTTP 416 was renamed to "Range Not Satisfiable" in RFC7233.
|
|
101
|
+
416 => ['RequestedRangeNotSatisfiable']
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
# This is the base Contractful exception class. Rescue it if you want to
|
|
105
|
+
# catch any exception that your request might raise
|
|
106
|
+
# You can get the status code by e.http_code, or see anything about the
|
|
107
|
+
# response via e.response.
|
|
108
|
+
# For example, the entire result body (which is
|
|
109
|
+
# probably an HTML error page) is e.response.
|
|
110
|
+
class Exception < RuntimeError
|
|
111
|
+
attr_accessor :response, :original_exception
|
|
112
|
+
attr_writer :message
|
|
113
|
+
|
|
114
|
+
def initialize response = nil, initial_response_code = nil
|
|
115
|
+
super()
|
|
116
|
+
@response = response
|
|
117
|
+
@message = nil
|
|
118
|
+
@initial_response_code = initial_response_code
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def http_code
|
|
122
|
+
# return integer for compatibility
|
|
123
|
+
if @response
|
|
124
|
+
@response.code.to_i
|
|
125
|
+
else
|
|
126
|
+
@initial_response_code
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def http_headers
|
|
131
|
+
@response&.headers
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def http_body
|
|
135
|
+
@response&.body
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# Part of the error model: is this failure transient (worth retrying)
|
|
139
|
+
# or terminal? Exceptions without a response are terminal by default;
|
|
140
|
+
# subclasses override (see {Exceptions::RETRIABLE_STATUSES} for the
|
|
141
|
+
# status classification of response-bearing errors).
|
|
142
|
+
def retryable?
|
|
143
|
+
false
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def to_s
|
|
147
|
+
message
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def message
|
|
151
|
+
@message || default_message
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def default_message
|
|
155
|
+
self.class.name
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# Compatibility
|
|
160
|
+
class ExceptionWithResponse < Contractful::Exception
|
|
161
|
+
# Retryable when the HTTP status itself classifies as transient
|
|
162
|
+
# (408/425/429 and the usual 5xx gateway/server errors — see
|
|
163
|
+
# {Exceptions::RETRIABLE_STATUSES}). Every other status is terminal.
|
|
164
|
+
def retryable?
|
|
165
|
+
Exceptions::RETRIABLE_STATUSES.include?(http_code)
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# The request failed with an error code not managed by the code
|
|
170
|
+
class RequestFailed < ExceptionWithResponse
|
|
171
|
+
def default_message
|
|
172
|
+
"HTTP status code #{http_code}"
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def to_s
|
|
176
|
+
message
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# Contractful exception classes. TODO: move all exceptions into this module.
|
|
181
|
+
#
|
|
182
|
+
# We will a create an exception for each status code, see
|
|
183
|
+
# http://www.w3.org/Protocols/rfc2616/rfc2616-sec10.html
|
|
184
|
+
#
|
|
185
|
+
module Exceptions
|
|
186
|
+
# Map http status codes to the corresponding exception class
|
|
187
|
+
EXCEPTIONS_MAP = {}
|
|
188
|
+
|
|
189
|
+
# HTTP statuses whose failures are TRANSIENT rather than terminal — the
|
|
190
|
+
# retryable side of the error model. 408 (request timeout), 425 (too
|
|
191
|
+
# early), 429 (rate limited) and the standard gateway/server breakdowns.
|
|
192
|
+
# Everything else (401, 404, 422, 501, ...) is terminal: retrying will
|
|
193
|
+
# not fix a bad token, a missing resource or a bad payload.
|
|
194
|
+
RETRIABLE_STATUSES = [408, 425, 429, 500, 502, 503, 504].freeze
|
|
195
|
+
|
|
196
|
+
# Network-level failure classes that classify as retryable. NOTE:
|
|
197
|
+
# ::Timeout::Error is referenced with a top-level scope because
|
|
198
|
+
# Contractful::Exceptions::Timeout shadows the Timeout module here.
|
|
199
|
+
def self.network_errors
|
|
200
|
+
@network_errors ||= begin
|
|
201
|
+
require 'net/http'
|
|
202
|
+
require 'timeout'
|
|
203
|
+
[
|
|
204
|
+
SocketError,
|
|
205
|
+
EOFError,
|
|
206
|
+
Errno::ECONNRESET,
|
|
207
|
+
Errno::ECONNABORTED,
|
|
208
|
+
Errno::EPIPE,
|
|
209
|
+
Errno::ECONNREFUSED,
|
|
210
|
+
Errno::EHOSTUNREACH,
|
|
211
|
+
Errno::ENETUNREACH,
|
|
212
|
+
::Timeout::Error,
|
|
213
|
+
Net::OpenTimeout,
|
|
214
|
+
Net::ReadTimeout,
|
|
215
|
+
Net::WriteTimeout
|
|
216
|
+
].freeze
|
|
217
|
+
end
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
# Classify a failure as retryable (transient) or terminal.
|
|
221
|
+
#
|
|
222
|
+
# Accepts an Integer HTTP status, a Contractful::Exception instance, or
|
|
223
|
+
# any other exception object (checked against {network_errors}).
|
|
224
|
+
#
|
|
225
|
+
# Exceptions.retryable?(429) # => true
|
|
226
|
+
# Exceptions.retryable?(404) # => false
|
|
227
|
+
# Exceptions.retryable?(Contractful::NotFound.new) # => false
|
|
228
|
+
# Exceptions.retryable?(SocketError.new('boom')) # => true
|
|
229
|
+
def self.retryable?(candidate)
|
|
230
|
+
case candidate
|
|
231
|
+
when Integer
|
|
232
|
+
RETRIABLE_STATUSES.include?(candidate)
|
|
233
|
+
when Contractful::Exception
|
|
234
|
+
candidate.retryable?
|
|
235
|
+
else
|
|
236
|
+
network_errors.any? { |klass| candidate.is_a?(klass) }
|
|
237
|
+
end
|
|
238
|
+
end
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
# Create HTTP status exception classes
|
|
242
|
+
STATUSES.each_pair do |code, message|
|
|
243
|
+
klass = Class.new(RequestFailed) do
|
|
244
|
+
send(:define_method, :default_message) { "#{"#{http_code} " if http_code}#{message}" }
|
|
245
|
+
end
|
|
246
|
+
klass_constant = const_set(message.delete(' \-\''), klass)
|
|
247
|
+
Exceptions::EXCEPTIONS_MAP[code] = klass_constant
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
# Create HTTP status exception classes used for backwards compatibility
|
|
251
|
+
STATUSES_COMPATIBILITY.each_pair do |code, compat_list|
|
|
252
|
+
klass = Exceptions::EXCEPTIONS_MAP.fetch(code)
|
|
253
|
+
compat_list.each do |old_name|
|
|
254
|
+
const_set(old_name, klass)
|
|
255
|
+
end
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
module Exceptions
|
|
259
|
+
# We have to split the Exceptions module like we do here because the
|
|
260
|
+
# EXCEPTIONS_MAP is under Exceptions, but we depend on
|
|
261
|
+
# Contractful::RequestTimeout below.
|
|
262
|
+
|
|
263
|
+
# Base class for request timeouts.
|
|
264
|
+
#
|
|
265
|
+
# NB: Previous releases of contractful would raise RequestTimeout both for
|
|
266
|
+
# HTTP 408 responses and for actual connection timeouts.
|
|
267
|
+
class Timeout < Contractful::RequestTimeout
|
|
268
|
+
def initialize(message = nil, original_exception = nil)
|
|
269
|
+
super(nil, nil)
|
|
270
|
+
self.message = message if message
|
|
271
|
+
self.original_exception = original_exception if original_exception
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
# A network timeout never carries an HTTP status, so the status-based
|
|
275
|
+
# classification cannot see it — network timeouts are transient by
|
|
276
|
+
# definition.
|
|
277
|
+
def retryable?
|
|
278
|
+
true
|
|
279
|
+
end
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
# Timeout when connecting to a server. Typically wraps Net::OpenTimeout (in
|
|
283
|
+
# ruby 2.0 or greater).
|
|
284
|
+
class OpenTimeout < Timeout
|
|
285
|
+
def default_message
|
|
286
|
+
'Timed out connecting to server'
|
|
287
|
+
end
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
# Timeout when reading from a server. Typically wraps Net::ReadTimeout (in
|
|
291
|
+
# ruby 2.0 or greater).
|
|
292
|
+
class ReadTimeout < Timeout
|
|
293
|
+
def default_message
|
|
294
|
+
'Timed out reading data from server'
|
|
295
|
+
end
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
# Timeout when writing to a server. Typically wraps Net::WriteTimeout.
|
|
299
|
+
class WriteTimeout < Timeout
|
|
300
|
+
def default_message
|
|
301
|
+
'Timed out writing data to server'
|
|
302
|
+
end
|
|
303
|
+
end
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
# The server broke the connection prior to the request completing. Usually
|
|
307
|
+
# this means it crashed, or sometimes that your network connection was
|
|
308
|
+
# severed before it could complete.
|
|
309
|
+
class ServerBrokeConnection < Contractful::Exception
|
|
310
|
+
def initialize(message = 'Server broke connection')
|
|
311
|
+
super(nil, nil)
|
|
312
|
+
self.message = message
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
def retryable?
|
|
316
|
+
true
|
|
317
|
+
end
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
# Raised when an HTTPS response fails certificate verification while
|
|
321
|
+
# verify_ssl is enabled.
|
|
322
|
+
class SSLCertificateNotVerified < Contractful::Exception
|
|
323
|
+
def initialize(message = 'SSL certificate not verified')
|
|
324
|
+
super(nil, nil)
|
|
325
|
+
self.message = message
|
|
326
|
+
end
|
|
327
|
+
end
|
|
328
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Optional bridge from contractful's internal notification hub to
|
|
4
|
+
# ActiveSupport::Notifications (the Rails-ecosystem standard). Loading this
|
|
5
|
+
# file — never loaded by <tt>require 'contractful'</tt> — forwards every
|
|
6
|
+
# {Contractful::Notifications} event to AS::Notifications so Rails
|
|
7
|
+
# subscribers, LogSubscribers and APM agents receive them natively:
|
|
8
|
+
#
|
|
9
|
+
# require 'contractful'
|
|
10
|
+
# require 'contractful/notifications/active_support'
|
|
11
|
+
#
|
|
12
|
+
# ActiveSupport::Notifications.subscribe(/^contractful\./) do |*args|
|
|
13
|
+
# event = ActiveSupport::Notifications::Event.new(*args)
|
|
14
|
+
# Rails.logger.info "#{event.name} #{event.payload.inspect} (#{event.duration}ms)"
|
|
15
|
+
# end
|
|
16
|
+
#
|
|
17
|
+
# Each event is forwarded as <tt>contractful.<name></tt> with the event
|
|
18
|
+
# payload as instrument payload (plus the monotonic <tt>:at</tt> timestamp).
|
|
19
|
+
# Unsubscribe from the internal hub with
|
|
20
|
+
# <tt>Contractful::Notifications.clear!</tt> to detach the bridge.
|
|
21
|
+
#
|
|
22
|
+
# Requires the +activesupport+ gem (>= 6.0) at load time; it is an optional
|
|
23
|
+
# integration and never a runtime dependency of the gem itself.
|
|
24
|
+
|
|
25
|
+
# 'active_support' rather than just 'active_support/notifications': since
|
|
26
|
+
# ActiveSupport 7.1 the notifications module depends on
|
|
27
|
+
# IsolatedExecutionState and friends and is not self-sufficient standalone.
|
|
28
|
+
begin
|
|
29
|
+
require 'active_support'
|
|
30
|
+
rescue LoadError => e
|
|
31
|
+
raise LoadError,
|
|
32
|
+
"contractful's ActiveSupport bridge requires the activesupport gem " \
|
|
33
|
+
"(add `gem 'activesupport'` to your Gemfile): #{e.message}"
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
Contractful::Notifications.subscribe do |event|
|
|
37
|
+
ActiveSupport::Notifications.instrument(
|
|
38
|
+
"contractful.#{event[:name]}", **event[:payload], at: event[:at]
|
|
39
|
+
)
|
|
40
|
+
end
|