wcc-contentful 0.3.0 → 1.0.0.pre.rc2

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 (99) hide show
  1. checksums.yaml +5 -5
  2. data/.rspec +1 -1
  3. data/Guardfile +43 -0
  4. data/README.md +161 -11
  5. data/Rakefile +3 -6
  6. data/app/controllers/wcc/contentful/webhook_controller.rb +25 -24
  7. data/app/jobs/wcc/contentful/webhook_enable_job.rb +36 -2
  8. data/bin/console +4 -3
  9. data/bin/rails +2 -0
  10. data/config/routes.rb +1 -1
  11. data/doc +1 -0
  12. data/lib/tasks/download_schema.rake +12 -0
  13. data/lib/wcc/contentful.rb +69 -45
  14. data/lib/wcc/contentful/active_record_shim.rb +72 -0
  15. data/lib/wcc/contentful/configuration.rb +177 -46
  16. data/lib/wcc/contentful/content_type_indexer.rb +14 -0
  17. data/lib/wcc/contentful/downloads_schema.rb +112 -0
  18. data/lib/wcc/contentful/engine.rb +33 -14
  19. data/lib/wcc/contentful/event.rb +171 -0
  20. data/lib/wcc/contentful/events.rb +41 -0
  21. data/lib/wcc/contentful/exceptions.rb +3 -33
  22. data/lib/wcc/contentful/indexed_representation.rb +2 -2
  23. data/lib/wcc/contentful/instrumentation.rb +31 -0
  24. data/lib/wcc/contentful/link.rb +28 -0
  25. data/lib/wcc/contentful/link_visitor.rb +122 -0
  26. data/lib/wcc/contentful/middleware.rb +7 -0
  27. data/lib/wcc/contentful/middleware/store.rb +158 -0
  28. data/lib/wcc/contentful/middleware/store/caching_middleware.rb +114 -0
  29. data/lib/wcc/contentful/model.rb +37 -4
  30. data/lib/wcc/contentful/model_builder.rb +1 -0
  31. data/lib/wcc/contentful/model_methods.rb +40 -15
  32. data/lib/wcc/contentful/model_singleton_methods.rb +47 -30
  33. data/lib/wcc/contentful/rake.rb +4 -0
  34. data/lib/wcc/contentful/rspec.rb +46 -0
  35. data/lib/wcc/contentful/services.rb +61 -27
  36. data/lib/wcc/contentful/simple_client.rb +81 -25
  37. data/lib/wcc/contentful/simple_client/management.rb +43 -10
  38. data/lib/wcc/contentful/simple_client/response.rb +61 -22
  39. data/lib/wcc/contentful/simple_client/typhoeus_adapter.rb +17 -17
  40. data/lib/wcc/contentful/store.rb +7 -66
  41. data/lib/wcc/contentful/store/README.md +85 -0
  42. data/lib/wcc/contentful/store/base.rb +34 -119
  43. data/lib/wcc/contentful/store/cdn_adapter.rb +71 -12
  44. data/lib/wcc/contentful/store/factory.rb +186 -0
  45. data/lib/wcc/contentful/store/instrumentation.rb +55 -0
  46. data/lib/wcc/contentful/store/interface.rb +82 -0
  47. data/lib/wcc/contentful/store/memory_store.rb +27 -24
  48. data/lib/wcc/contentful/store/postgres_store.rb +268 -101
  49. data/lib/wcc/contentful/store/postgres_store/schema_1.sql +73 -0
  50. data/lib/wcc/contentful/store/postgres_store/schema_2.sql +21 -0
  51. data/lib/wcc/contentful/store/query.rb +246 -0
  52. data/lib/wcc/contentful/store/query/interface.rb +63 -0
  53. data/lib/wcc/contentful/store/rspec_examples.rb +48 -0
  54. data/lib/wcc/contentful/store/rspec_examples/basic_store.rb +629 -0
  55. data/lib/wcc/contentful/store/rspec_examples/include_param.rb +283 -0
  56. data/lib/wcc/contentful/store/rspec_examples/nested_queries.rb +342 -0
  57. data/lib/wcc/contentful/sync_engine.rb +181 -0
  58. data/lib/wcc/contentful/test.rb +7 -0
  59. data/lib/wcc/contentful/test/attributes.rb +56 -0
  60. data/lib/wcc/contentful/test/double.rb +76 -0
  61. data/lib/wcc/contentful/test/factory.rb +101 -0
  62. data/lib/wcc/contentful/version.rb +1 -1
  63. data/wcc-contentful.gemspec +28 -14
  64. metadata +248 -152
  65. data/.circleci/config.yml +0 -51
  66. data/.gitignore +0 -26
  67. data/.rubocop.yml +0 -242
  68. data/.rubocop_todo.yml +0 -19
  69. data/.travis.yml +0 -5
  70. data/CHANGELOG.md +0 -180
  71. data/CODE_OF_CONDUCT.md +0 -74
  72. data/Gemfile +0 -8
  73. data/LICENSE.txt +0 -21
  74. data/app/jobs/wcc/contentful/delayed_sync_job.rb +0 -63
  75. data/lib/generators/wcc/USAGE +0 -24
  76. data/lib/generators/wcc/model_generator.rb +0 -90
  77. data/lib/generators/wcc/templates/.keep +0 -0
  78. data/lib/generators/wcc/templates/Procfile +0 -3
  79. data/lib/generators/wcc/templates/contentful_shell_wrapper +0 -385
  80. data/lib/generators/wcc/templates/menu/generated_add_menus.ts +0 -192
  81. data/lib/generators/wcc/templates/menu/models/menu.rb +0 -23
  82. data/lib/generators/wcc/templates/menu/models/menu_button.rb +0 -23
  83. data/lib/generators/wcc/templates/page/generated_add_pages.ts +0 -50
  84. data/lib/generators/wcc/templates/page/models/page.rb +0 -23
  85. data/lib/generators/wcc/templates/release +0 -9
  86. data/lib/generators/wcc/templates/wcc_contentful.rb +0 -17
  87. data/lib/wcc/contentful/client_ext.rb +0 -28
  88. data/lib/wcc/contentful/graphql.rb +0 -14
  89. data/lib/wcc/contentful/graphql/builder.rb +0 -177
  90. data/lib/wcc/contentful/graphql/types.rb +0 -54
  91. data/lib/wcc/contentful/model/dropdown_menu.rb +0 -7
  92. data/lib/wcc/contentful/model/menu.rb +0 -6
  93. data/lib/wcc/contentful/model/menu_button.rb +0 -16
  94. data/lib/wcc/contentful/model/page.rb +0 -8
  95. data/lib/wcc/contentful/model/redirect.rb +0 -19
  96. data/lib/wcc/contentful/model_validators.rb +0 -121
  97. data/lib/wcc/contentful/model_validators/dsl.rb +0 -166
  98. data/lib/wcc/contentful/simple_client/http_adapter.rb +0 -24
  99. data/lib/wcc/contentful/store/lazy_cache_store.rb +0 -161
@@ -2,6 +2,7 @@
2
2
 
3
3
  require_relative 'simple_client/response'
4
4
  require_relative 'simple_client/management'
5
+ require_relative 'instrumentation'
5
6
 
6
7
  module WCC::Contentful
7
8
  # The SimpleClient accesses the Contentful CDN to get JSON responses,
@@ -16,11 +17,13 @@ module WCC::Contentful
16
17
  # `get`. This method returns a WCC::Contentful::SimpleClient::Response
17
18
  # that handles paging automatically.
18
19
  #
19
- # The SimpleClient by default uses 'http' to perform the gets, but any HTTP
20
- # client can be injected by passing a proc as the `adapter:` option.
20
+ # The SimpleClient by default uses 'faraday' to perform the gets, but any HTTP
21
+ # client adapter be injected by passing the `connection:` option.
21
22
  #
22
23
  # @api Client
23
24
  class SimpleClient
25
+ include WCC::Contentful::Instrumentation
26
+
24
27
  attr_reader :api_url
25
28
  attr_reader :space
26
29
 
@@ -30,21 +33,27 @@ module WCC::Contentful
30
33
  # @param [String] space The Space ID to access
31
34
  # @param [String] access_token A Contentful Access Token to be sent in the Authorization header
32
35
  # @param [Hash] options The remaining optional parameters, defined below
33
- # @option options [Symbol, Object] adapter The Adapter to use to make requests.
36
+ # @option options [Symbol, Object] connection The Faraday connection to use to make requests.
34
37
  # Auto-discovered based on what gems are installed if this is not provided.
35
38
  # @option options [String] default_locale The locale query param to set by default.
36
39
  # @option options [String] environment The contentful environment to access. Defaults to 'master'.
37
40
  # @option options [Boolean] no_follow_redirects If true, do not follow 300 level redirects.
41
+ # @option options [Number] rate_limit_wait_timeout The maximum time to block the thread waiting
42
+ # on a rate limit response. By default will wait for one 429 and then fail on the second 429.
38
43
  def initialize(api_url:, space:, access_token:, **options)
39
44
  @api_url = URI.join(api_url, '/spaces/', space + '/')
40
45
  @space = space
41
46
  @access_token = access_token
42
47
 
43
- @adapter = SimpleClient.load_adapter(options[:adapter])
48
+ @adapter = SimpleClient.load_adapter(options[:connection])
44
49
 
45
50
  @options = options
51
+ @_instrumentation = @options[:instrumentation]
46
52
  @query_defaults = {}
47
53
  @query_defaults[:locale] = @options[:default_locale] if @options[:default_locale]
54
+ # default 1.5 so that we retry one time then fail if still rate limited
55
+ # https://www.contentful.com/developers/docs/references/content-preview-api/#/introduction/api-rate-limits
56
+ @rate_limit_wait_timeout = @options[:rate_limit_wait_timeout] || 1.5
48
57
 
49
58
  return unless options[:environment].present?
50
59
 
@@ -57,13 +66,17 @@ module WCC::Contentful
57
66
  def get(path, query = {})
58
67
  url = URI.join(@api_url, path)
59
68
 
69
+ resp =
70
+ _instrument 'get_http', url: url, query: query do
71
+ get_http(url, query)
72
+ end
60
73
  Response.new(self,
61
74
  { url: url, query: query },
62
- get_http(url, query))
75
+ resp)
63
76
  end
64
77
 
65
78
  ADAPTERS = {
66
- http: ['http', '> 1.0', '< 3.0'],
79
+ faraday: ['faraday', '>= 0.9'],
67
80
  typhoeus: ['typhoeus', '~> 1.0']
68
81
  }.freeze
69
82
 
@@ -80,16 +93,19 @@ module WCC::Contentful
80
93
  end
81
94
  raise ArgumentError, 'Unable to load adapter! Please install one of '\
82
95
  "#{ADAPTERS.values.map(&:join).join(',')}"
83
- when :http
84
- require_relative 'simple_client/http_adapter'
85
- HttpAdapter.new
96
+ when :faraday
97
+ require 'faraday'
98
+ ::Faraday.new do |faraday|
99
+ faraday.response :logger, (Rails.logger if defined?(Rails)), { headers: false, bodies: false }
100
+ faraday.adapter :net_http
101
+ end
86
102
  when :typhoeus
87
103
  require_relative 'simple_client/typhoeus_adapter'
88
104
  TyphoeusAdapter.new
89
105
  else
90
- unless adapter.respond_to?(:call)
106
+ unless adapter.respond_to?(:get)
91
107
  raise ArgumentError, "Adapter #{adapter} is not invokeable! Please "\
92
- "pass a proc or use one of #{ADAPTERS.keys}"
108
+ "pass use one of #{ADAPTERS.keys} or create a Faraday-compatible adapter"
93
109
  end
94
110
  adapter
95
111
  end
@@ -97,7 +113,12 @@ module WCC::Contentful
97
113
 
98
114
  private
99
115
 
100
- def get_http(url, query, headers = {}, proxy = {})
116
+ def _instrumentation_event_prefix
117
+ # Unify all CDN, Management, Preview notifications under same namespace
118
+ '.simpleclient.contentful.wcc'
119
+ end
120
+
121
+ def get_http(url, query, headers = {})
101
122
  headers = {
102
123
  Authorization: "Bearer #{@access_token}"
103
124
  }.merge(headers || {})
@@ -105,12 +126,28 @@ module WCC::Contentful
105
126
  q = @query_defaults.dup
106
127
  q = q.merge(query) if query
107
128
 
108
- resp = @adapter.call(url, q, headers, proxy)
129
+ start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
130
+ loop do
131
+ resp = @adapter.get(url, q, headers)
132
+
133
+ if [301, 302, 307].include?(resp.status) && !@options[:no_follow_redirects]
134
+ url = resp.headers['Location']
135
+ next
136
+ end
137
+
138
+ if resp.status == 429 &&
139
+ reset = resp.headers['X-Contentful-RateLimit-Reset'].presence
140
+ reset = reset.to_f
141
+ _instrument 'rate_limit', start: start, reset: reset, timeout: @rate_limit_wait_timeout
142
+ now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
143
+ if (now - start) + reset < @rate_limit_wait_timeout
144
+ sleep(reset)
145
+ next
146
+ end
147
+ end
109
148
 
110
- if [301, 302, 307].include?(resp.code) && !@options[:no_follow_redirects]
111
- resp = get_http(resp.headers['location'], nil, headers, proxy)
149
+ return resp
112
150
  end
113
- resp
114
151
  end
115
152
 
116
153
  # The CDN SimpleClient accesses 'https://cdn.contentful.com' to get raw
@@ -135,31 +172,46 @@ module WCC::Contentful
135
172
 
136
173
  # Gets an entry by ID
137
174
  def entry(key, query = {})
138
- resp = get("entries/#{key}", query)
175
+ resp =
176
+ _instrument 'entries', id: key, type: 'Entry', query: query do
177
+ get("entries/#{key}", query)
178
+ end
139
179
  resp.assert_ok!
140
180
  end
141
181
 
142
182
  # Queries entries with optional query parameters
143
183
  def entries(query = {})
144
- resp = get('entries', query)
184
+ resp =
185
+ _instrument 'entries', type: 'Entry', query: query do
186
+ get('entries', query)
187
+ end
145
188
  resp.assert_ok!
146
189
  end
147
190
 
148
191
  # Gets an asset by ID
149
192
  def asset(key, query = {})
150
- resp = get("assets/#{key}", query)
193
+ resp =
194
+ _instrument 'entries', type: 'Asset', id: key, query: query do
195
+ get("assets/#{key}", query)
196
+ end
151
197
  resp.assert_ok!
152
198
  end
153
199
 
154
200
  # Queries assets with optional query parameters
155
201
  def assets(query = {})
156
- resp = get('assets', query)
202
+ resp =
203
+ _instrument 'entries', type: 'Asset', query: query do
204
+ get('assets', query)
205
+ end
157
206
  resp.assert_ok!
158
207
  end
159
208
 
160
209
  # Queries content types with optional query parameters
161
210
  def content_types(query = {})
162
- resp = get('content_types', query)
211
+ resp =
212
+ _instrument 'content_types', query: query do
213
+ get('content_types', query)
214
+ end
163
215
  resp.assert_ok!
164
216
  end
165
217
 
@@ -177,7 +229,11 @@ module WCC::Contentful
177
229
  { initial: true }
178
230
  end
179
231
  query = query.merge(sync_token)
180
- resp = SyncResponse.new(get('sync', query))
232
+ resp =
233
+ _instrument 'sync', sync_token: sync_token, query: query do
234
+ get('sync', query)
235
+ end
236
+ resp = SyncResponse.new(resp)
181
237
  resp.assert_ok!
182
238
  end
183
239
  end
@@ -186,10 +242,10 @@ module WCC::Contentful
186
242
  class Preview < Cdn
187
243
  def initialize(space:, preview_token:, **options)
188
244
  super(
189
- api_url: options[:api_url] || 'https://preview.contentful.com/',
245
+ **options,
246
+ api_url: options[:preview_api_url] || 'https://preview.contentful.com/',
190
247
  space: space,
191
- access_token: preview_token,
192
- **options
248
+ access_token: preview_token
193
249
  )
194
250
  end
195
251
 
@@ -4,10 +4,10 @@
4
4
  class WCC::Contentful::SimpleClient::Management < WCC::Contentful::SimpleClient
5
5
  def initialize(space:, management_token:, **options)
6
6
  super(
7
- api_url: options[:api_url] || 'https://api.contentful.com',
7
+ **options,
8
+ api_url: options[:management_api_url] || 'https://api.contentful.com',
8
9
  space: space,
9
10
  access_token: management_token,
10
- **options
11
11
  )
12
12
 
13
13
  @post_adapter = @adapter if @adapter.respond_to?(:post)
@@ -19,12 +19,34 @@ class WCC::Contentful::SimpleClient::Management < WCC::Contentful::SimpleClient
19
19
  end
20
20
 
21
21
  def content_types(**query)
22
- resp = get('content_types', query)
22
+ resp =
23
+ _instrument 'content_types', query: query do
24
+ get('content_types', query)
25
+ end
26
+ resp.assert_ok!
27
+ end
28
+
29
+ def content_type(key, query = {})
30
+ resp =
31
+ _instrument 'content_types', content_type: key, query: query do
32
+ get("content_types/#{key}", query)
33
+ end
34
+ resp.assert_ok!
35
+ end
36
+
37
+ def editor_interface(content_type_id, query = {})
38
+ resp =
39
+ _instrument 'editor_interfaces', content_type: content_type_id, query: query do
40
+ get("content_types/#{content_type_id}/editor_interface", query)
41
+ end
23
42
  resp.assert_ok!
24
43
  end
25
44
 
26
45
  def webhook_definitions(**query)
27
- resp = get("/spaces/#{space}/webhook_definitions", query)
46
+ resp =
47
+ _instrument 'webhook_definitions', query: query do
48
+ get("/spaces/#{space}/webhook_definitions", query)
49
+ end
28
50
  resp.assert_ok!
29
51
  end
30
52
 
@@ -51,30 +73,41 @@ class WCC::Contentful::SimpleClient::Management < WCC::Contentful::SimpleClient
51
73
  # ]
52
74
  # }
53
75
  def post_webhook_definition(webhook)
54
- resp = post("/spaces/#{space}/webhook_definitions", webhook)
76
+ resp =
77
+ _instrument 'post.webhook_definitions' do
78
+ post("/spaces/#{space}/webhook_definitions", webhook)
79
+ end
55
80
  resp.assert_ok!
56
81
  end
57
82
 
58
83
  def post(path, body)
59
84
  url = URI.join(@api_url, path)
60
85
 
86
+ resp =
87
+ _instrument 'post_http', url: url do
88
+ post_http(url, body)
89
+ end
90
+
61
91
  Response.new(self,
62
92
  { url: url, body: body },
63
- post_http(url, body))
93
+ resp)
64
94
  end
65
95
 
66
96
  private
67
97
 
68
- def post_http(url, body, headers = {}, proxy = {})
98
+ def post_http(url, body, headers = {})
69
99
  headers = {
70
100
  Authorization: "Bearer #{@access_token}",
71
101
  'Content-Type' => 'application/vnd.contentful.management.v1+json'
72
102
  }.merge(headers || {})
73
103
 
74
- resp = @post_adapter.post(url, body, headers, proxy)
104
+ body = body.to_json unless body.is_a? String
105
+ resp = @post_adapter.post(url, body, headers)
75
106
 
76
- if [301, 302, 307].include?(resp.code) && !@options[:no_follow_redirects]
77
- resp = get_http(resp.headers['location'], nil, headers, proxy)
107
+ if [301, 302, 307].include?(resp.status) && !@options[:no_follow_redirects]
108
+ resp = get_http(resp.headers['location'], nil, headers)
109
+ elsif resp.status == 308 && !@options[:no_follow_redirects]
110
+ resp = post_http(resp.headers['location'], body, headers)
78
111
  end
79
112
  resp
80
113
  end
@@ -1,12 +1,17 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative '../instrumentation'
4
+
3
5
  class WCC::Contentful::SimpleClient
4
6
  class Response
7
+ include ::WCC::Contentful::Instrumentation
8
+
5
9
  attr_reader :raw_response
6
10
  attr_reader :client
7
11
  attr_reader :request
8
12
 
9
- delegate :code, to: :raw_response
13
+ delegate :status, to: :raw_response
14
+ alias_method :code, :status
10
15
  delegate :headers, to: :raw_response
11
16
 
12
17
  def body
@@ -19,25 +24,41 @@ class WCC::Contentful::SimpleClient
19
24
  alias_method :to_json, :raw
20
25
 
21
26
  def error_message
22
- raw.dig('message') || "#{code}: #{raw_response.message}"
27
+ parsed_message =
28
+ begin
29
+ raw.dig('message')
30
+ rescue JSON::ParserError
31
+ nil
32
+ end
33
+ parsed_message || "#{code}: #{raw_response.body}"
34
+ end
35
+
36
+ def skip
37
+ raw['skip']
38
+ end
39
+
40
+ def total
41
+ raw['total']
23
42
  end
24
43
 
25
44
  def next_page?
26
45
  return unless raw.key? 'items'
27
46
 
28
- raw['items'].length + raw['skip'] < raw['total']
47
+ page_items.length + skip < total
29
48
  end
30
49
 
31
50
  def next_page
32
51
  return unless next_page?
33
-
34
- @next_page ||= @client.get(
35
- @request[:url],
36
- (@request[:query] || {}).merge({
37
- skip: raw['items'].length + raw['skip']
38
- })
39
- )
40
- @next_page.assert_ok!
52
+ return @next_page if @next_page
53
+
54
+ query = (@request[:query] || {}).merge({
55
+ skip: page_items.length + skip
56
+ })
57
+ np =
58
+ _instrument 'page', url: @request[:url], query: query do
59
+ @client.get(@request[:url], query)
60
+ end
61
+ @next_page = np.assert_ok!
41
62
  end
42
63
 
43
64
  def initialize(client, request, raw_response)
@@ -48,13 +69,13 @@ class WCC::Contentful::SimpleClient
48
69
  end
49
70
 
50
71
  def assert_ok!
51
- return self if code >= 200 && code < 300
72
+ return self if status >= 200 && status < 300
52
73
 
53
- raise ApiError[code], self
74
+ raise ApiError[status], self
54
75
  end
55
76
 
56
77
  def each_page(&block)
57
- raise ArgumentError, 'Not a collection response' unless raw['items']
78
+ raise ArgumentError, 'Not a collection response' unless page_items
58
79
 
59
80
  ret =
60
81
  Enumerator.new do |y|
@@ -75,19 +96,21 @@ class WCC::Contentful::SimpleClient
75
96
  end
76
97
 
77
98
  def items
78
- each_page.flat_map do |page|
79
- page.raw['items']
80
- end
99
+ each_page.flat_map(&:page_items)
100
+ end
101
+
102
+ def page_items
103
+ raw['items']
81
104
  end
82
105
 
83
106
  def count
84
- raw['total']
107
+ total
85
108
  end
86
109
 
87
110
  def first
88
- raise ArgumentError, 'Not a collection response' unless raw['items']
111
+ raise ArgumentError, 'Not a collection response' unless page_items
89
112
 
90
- raw['items'].first
113
+ page_items.first
91
114
  end
92
115
 
93
116
  def includes
@@ -115,7 +138,13 @@ class WCC::Contentful::SimpleClient
115
138
  def next_page
116
139
  return unless next_page?
117
140
 
118
- @next_page ||= SyncResponse.new(@client.get(raw['nextPageUrl']))
141
+ url = raw['nextPageUrl']
142
+ next_page =
143
+ _instrument 'page', url: url do
144
+ @client.get(url)
145
+ end
146
+
147
+ @next_page ||= SyncResponse.new(next_page)
119
148
  @next_page.assert_ok!
120
149
  end
121
150
 
@@ -129,7 +158,7 @@ class WCC::Contentful::SimpleClient
129
158
  end
130
159
 
131
160
  def each_page
132
- raise ArgumentError, 'Not a collection response' unless raw['items']
161
+ raise ArgumentError, 'Not a collection response' unless page_items
133
162
 
134
163
  ret =
135
164
  Enumerator.new do |y|
@@ -168,6 +197,10 @@ class WCC::Contentful::SimpleClient
168
197
  case code
169
198
  when 404
170
199
  NotFoundError
200
+ when 401
201
+ UnauthorizedError
202
+ when 429
203
+ RateLimitError
171
204
  else
172
205
  ApiError
173
206
  end
@@ -181,4 +214,10 @@ class WCC::Contentful::SimpleClient
181
214
 
182
215
  class NotFoundError < ApiError
183
216
  end
217
+
218
+ class UnauthorizedError < ApiError
219
+ end
220
+
221
+ class RateLimitError < ApiError
222
+ end
184
223
  end