stoplight 0.4.1 → 0.5.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (40) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +15 -0
  3. data/LICENSE.md +1 -1
  4. data/README.md +66 -63
  5. data/lib/stoplight.rb +10 -15
  6. data/lib/stoplight/color.rb +9 -0
  7. data/lib/stoplight/data_store.rb +0 -146
  8. data/lib/stoplight/data_store/base.rb +7 -130
  9. data/lib/stoplight/data_store/memory.rb +25 -100
  10. data/lib/stoplight/data_store/redis.rb +61 -119
  11. data/lib/stoplight/default.rb +34 -0
  12. data/lib/stoplight/error.rb +0 -42
  13. data/lib/stoplight/failure.rb +21 -25
  14. data/lib/stoplight/light.rb +42 -127
  15. data/lib/stoplight/light/runnable.rb +97 -0
  16. data/lib/stoplight/notifier/base.rb +1 -4
  17. data/lib/stoplight/notifier/hip_chat.rb +17 -32
  18. data/lib/stoplight/notifier/io.rb +9 -9
  19. data/lib/stoplight/state.rb +9 -0
  20. data/spec/spec_helper.rb +2 -3
  21. data/spec/stoplight/color_spec.rb +39 -0
  22. data/spec/stoplight/data_store/base_spec.rb +56 -36
  23. data/spec/stoplight/data_store/memory_spec.rb +120 -2
  24. data/spec/stoplight/data_store/redis_spec.rb +123 -24
  25. data/spec/stoplight/data_store_spec.rb +2 -69
  26. data/spec/stoplight/default_spec.rb +86 -0
  27. data/spec/stoplight/error_spec.rb +29 -0
  28. data/spec/stoplight/failure_spec.rb +61 -51
  29. data/spec/stoplight/light/runnable_spec.rb +234 -0
  30. data/spec/stoplight/light_spec.rb +143 -191
  31. data/spec/stoplight/notifier/base_spec.rb +8 -11
  32. data/spec/stoplight/notifier/hip_chat_spec.rb +66 -55
  33. data/spec/stoplight/notifier/io_spec.rb +49 -21
  34. data/spec/stoplight/notifier_spec.rb +3 -0
  35. data/spec/stoplight/state_spec.rb +39 -0
  36. data/spec/stoplight_spec.rb +2 -65
  37. metadata +55 -19
  38. data/spec/support/data_store.rb +0 -36
  39. data/spec/support/fakeredis.rb +0 -3
  40. data/spec/support/hipchat.rb +0 -3
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA1:
3
- metadata.gz: 314c63808a80e427881c2d09152f74e68f4ffd37
4
- data.tar.gz: 67ab9ca03f20512f5b119b95eec17494d7fdf726
3
+ metadata.gz: ad268d747b85be7b3a0d773d9aeec70b1b22c25a
4
+ data.tar.gz: 56c5c8f88361a3c9d705212d9725251017097ddb
5
5
  SHA512:
6
- metadata.gz: 5a758d0cd6beeb2b0c034f424ec4b4b2fc1fe72f11a7eb598fe195e408437ec6ba3dd95aa9b29554d5a9e8a0727fabbfdc46aade4a7c1674cf457467459032c5
7
- data.tar.gz: 50eb0565526a389406422fdca3b93a9ce4923bd7ab7e50aa558b976ca09ed56ee976c9bcc3fc4b0f72c6a28290964e4a676d1c6dbb741ae45fed26b9f30619ab
6
+ metadata.gz: 043418c2e767031b2a229ad2e22d378c52101d342cdec3bb600bec88c5c46238c345eadb558d779ccedb8f60122b81b5cddf969dfb7dbf475e7bfe3e0cb7b3c2
7
+ data.tar.gz: 8a9952d663eb04f03e05736f3541ac09b69e69776c80265116d43a7c4c4e2e8d699691598ef8ae35d1c0018b5bc7880f73614894d79e194987961e857c2f7016
data/CHANGELOG.md CHANGED
@@ -1,5 +1,18 @@
1
1
  # Changelog
2
2
 
3
+ This project uses [Semantic Versioning][1].
4
+
5
+ - Data stores and notifiers can be configured on a per-stoplight basis. This
6
+ allows stoplights to use stoplights internally.
7
+ - Stoplights use stoplights internally to wrap calls to data stores and
8
+ notifiers. This means they gracefully handle either going down.
9
+ - Data stores only store failures and states. Also failures are stored in a ring
10
+ buffer. This drastically reduces the amount of data stored.
11
+ - Stoplights will use the fallback (if it's given) when they fail while they're
12
+ green. This means they won't re-raise exceptions if you provide a fallback.
13
+ - Stoplights pass the error to their notifiers when transitioning from green to
14
+ red.
15
+
3
16
  ## v0.4.1 (2014-10-03)
4
17
 
5
18
  - Fixed a bug that caused green to red notifications to sometimes not be sent.
@@ -62,3 +75,5 @@
62
75
  ## v0.1.0 (2014-08-12)
63
76
 
64
77
  - Initial release.
78
+
79
+ [1]: http://semver.org/spec/v2.0.0.html
data/LICENSE.md CHANGED
@@ -1,4 +1,4 @@
1
- Copyright (c) 2014 Cameron Desautels & Taylor Fausak
1
+ Copyright (c) 2014 Cameron Desautels, Taylor Fausak & Justin Steffy
2
2
 
3
3
  Permission is hereby granted, free of charge, to any person obtaining a copy of
4
4
  this software and associated documentation files (the "Software"), to deal in
data/README.md CHANGED
@@ -33,7 +33,7 @@ Check out [stoplight-admin][12] for controlling your stoplights.
33
33
  Add it to your Gemfile:
34
34
 
35
35
  ``` rb
36
- gem 'stoplight', '~> 0.4.1'
36
+ gem 'stoplight', '~> 0.5.0'
37
37
  ```
38
38
 
39
39
  Or install it manually:
@@ -42,8 +42,6 @@ Or install it manually:
42
42
  $ gem install stoplight
43
43
  ```
44
44
 
45
- This project uses [Semantic Versioning][13].
46
-
47
45
  ## Setup
48
46
 
49
47
  ### Data store
@@ -53,22 +51,22 @@ Stoplight uses an in-memory data store out of the box.
53
51
  ``` irb
54
52
  >> require 'stoplight'
55
53
  => true
56
- >> Stoplight.data_store
54
+ >> Stoplight::Light.default_data_store
57
55
  => #<Stoplight::DataStore::Memory:...>
58
56
  ```
59
57
 
60
58
  If you want to use a persistent data store, you'll have to set it up. Currently
61
59
  the only supported persistent data store is Redis. Make sure you have [the Redis
62
- gem][14] installed before configuring Stoplight.
60
+ gem][13] installed before configuring Stoplight.
63
61
 
64
62
  ``` irb
65
63
  >> require 'redis'
66
64
  => true
67
- >> redis = Redis.new(url: 'redis://127.0.0.1:6379/0')
68
- => #<Redis:...>
65
+ >> redis = Redis.new
66
+ => #<Redis client ...>
69
67
  >> data_store = Stoplight::DataStore::Redis.new(redis)
70
68
  => #<Stoplight::DataStore::Redis:...>
71
- >> Stoplight.data_store = data_store
69
+ >> Stoplight::Light.default_data_store = data_store
72
70
  => #<Stoplight::DataStore::Redis:...>
73
71
  ```
74
72
 
@@ -77,22 +75,22 @@ gem][14] installed before configuring Stoplight.
77
75
  Stoplight sends notifications to standard error by default.
78
76
 
79
77
  ``` irb
80
- >> Stoplight.notifiers
78
+ >> Stoplight::Light.default_notifiers
81
79
  => [#<Stoplight::Notifier::IO:...>]
82
80
  ```
83
81
 
84
82
  If you want to send notifications elsewhere, you'll have to set them up.
85
83
  Currently the only other supported notifier is HipChat. Make sure you have [the
86
- HipChat gem][15] installed before configuring Stoplight.
84
+ HipChat gem][14] installed before configuring Stoplight.
87
85
 
88
86
  ``` irb
89
87
  >> require 'hipchat'
90
88
  => true
91
- >> hipchat = HipChat::Client.new('token')
89
+ >> hip_chat = HipChat::Client.new('token')
92
90
  => #<HipChat::Client:...>
93
- >> notifier = Stoplight::Notifier::HipChat.new(hipchat, 'room')
91
+ >> notifier = Stoplight::Notifier::HipChat.new(hip_chat, 'room')
94
92
  => #<Stoplight::Notifier::HipChat:...>
95
- >> Stoplight.notifiers << notifier
93
+ >> Stoplight::Light.default_notifiers += [notifier]
96
94
  => [#<Stoplight::Notifier::IO:...>, #<Stoplight::Notifier::HipChat:...>]
97
95
  ```
98
96
 
@@ -106,8 +104,8 @@ Stoplight:
106
104
  ``` rb
107
105
  # config/initializers/stoplight.rb
108
106
  require 'stoplight'
109
- Stoplight.data_store = Stoplight::DataStore::Redis.new(...)
110
- Stoplight.notifiers << Stoplight::Notifier::HipChat.new(...)
107
+ Stoplight::Light.default_data_store = Stoplight::DataStore::Redis.new(...)
108
+ Stoplight::Light.default_notifiers += [Stoplight::Notifier::HipChat.new(...)]
111
109
  ```
112
110
 
113
111
  ## Basic usage
@@ -125,8 +123,8 @@ the green state.
125
123
  ``` irb
126
124
  >> light.run
127
125
  => 3.142857142857143
128
- >> light.green?
129
- => true
126
+ >> light.color
127
+ => "green"
130
128
  ```
131
129
 
132
130
  If everything goes well, you shouldn't even be able to tell that you're using a
@@ -147,12 +145,12 @@ ZeroDivisionError: divided by 0
147
145
  >> light.run
148
146
  ZeroDivisionError: divided by 0
149
147
  >> light.run
148
+ Switching example-2 from green to red because ZeroDivisionError divided by 0
150
149
  ZeroDivisionError: divided by 0
151
150
  >> light.run
152
- Switching example-2 from green to red.
153
151
  Stoplight::Error::RedLight: example-2
154
- >> light.red?
155
- => true
152
+ >> light.color
153
+ => "red"
156
154
  ```
157
155
 
158
156
  When the stoplight changes from green to red, it will notify every configured
@@ -165,7 +163,7 @@ these are handled elsewhere in your stack and don't represent real failures. A
165
163
  good example is `ActiveRecord::RecordNotFound`.
166
164
 
167
165
  ``` irb
168
- >> light = Stoplight::Light.new('example-4') { User.find(123) }.
166
+ >> light = Stoplight::Light.new('example-3') { User.find(123) }.
169
167
  .. with_allowed_errors([ActiveRecord::RecordNotFound])
170
168
  => #<Stoplight::Light:...>
171
169
  >> light.run
@@ -174,28 +172,34 @@ ActiveRecord::RecordNotFound: Couldn't find User with ID=123
174
172
  ActiveRecord::RecordNotFound: Couldn't find User with ID=123
175
173
  >> light.run
176
174
  ActiveRecord::RecordNotFound: Couldn't find User with ID=123
177
- >> light.green?
178
- => true
175
+ >> light.color
176
+ => "green"
179
177
  ```
180
178
 
181
179
  ### Custom fallback
182
180
 
183
- Instead of raising a `Stoplight::Error::RedLight` error when in the red state,
184
- you can provide a block to be run. This is useful when there's a good default
185
- value for the block.
181
+ By default, stoplights will re-raise errors when they're green. When they're
182
+ red, they'll raise a `Stoplight::Error::RedLight` error. You can provide a
183
+ fallback that will be called in both of these cases. It will be passed the error
184
+ if the light was green.
186
185
 
187
186
  ``` irb
188
- >> light = Stoplight::Light.new('example-5') { fail }.
189
- .. with_fallback { [] }
190
- => #<Stoplight::Light:...>
187
+ >> light = Stoplight::Light.new('example-4') { 1 / 0 }.
188
+ .. with_fallback { |e| p e; 'default' }
189
+ => #<Stoplight::Light:..>
191
190
  >> light.run
192
- RuntimeError:
191
+ #<ZeroDivisionError: divided by 0>
192
+ => "default"
193
193
  >> light.run
194
- RuntimeError:
194
+ #<ZeroDivisionError: divided by 0>
195
+ => "default"
195
196
  >> light.run
196
- RuntimeError:
197
+ Switching example-4 from green to red because ZeroDivisionError divided by 0
198
+ #<ZeroDivisionError: divided by 0>
199
+ => "default"
197
200
  >> light.run
198
- => []
201
+ nil
202
+ => "default"
199
203
  ```
200
204
 
201
205
  ### Custom threshold
@@ -204,13 +208,14 @@ Some bits of code might be allowed to fail more or less frequently than others.
204
208
  You can configure this by setting a custom threshold in seconds.
205
209
 
206
210
  ``` irb
207
- >> light = Stoplight::Light.new('example-6') { fail }.
211
+ >> light = Stoplight::Light.new('example-5') { fail }.
208
212
  .. with_threshold(1)
209
213
  => #<Stoplight::Light:...>
210
214
  >> light.run
215
+ Switching example-5 from green to red because RuntimeError
211
216
  RuntimeError:
212
217
  >> light.run
213
- Stoplight::Error::RedLight: example-6
218
+ Stoplight::Error::RedLight: example-5
214
219
  ```
215
220
 
216
221
  ### Custom timeout
@@ -220,7 +225,7 @@ A light in the red state for longer than the timeout will transition to the
220
225
  yellow state. This timeout is customizable.
221
226
 
222
227
  ``` irb
223
- >> light = Stoplight::Light.new('example-7') { fail }.
228
+ >> light = Stoplight::Light.new('example-6') { fail }.
224
229
  .. with_timeout(1)
225
230
  => #<Stoplight::Light:...>
226
231
  >> light.run
@@ -228,14 +233,12 @@ RuntimeError:
228
233
  >> light.run
229
234
  RuntimeError:
230
235
  >> light.run
236
+ Switching example-6 from green to red because RuntimeError
231
237
  RuntimeError:
232
- >> light.run
233
- Switching example-7 from green to red.
234
- Stoplight::Error::RedLight: example-7
235
238
  >> sleep(1)
236
239
  => 1
237
- >> light.yellow?
238
- => true
240
+ >> light.color
241
+ => "yellow"
239
242
  >> light.run
240
243
  RuntimeError:
241
244
  ```
@@ -254,7 +257,10 @@ class ApplicationController < ActionController::Base
254
257
  def stoplight(&block)
255
258
  Stoplight::Light.new("#{params[:controller]}##{params[:action]}", &block)
256
259
  .with_allowed_errors([ActiveRecord::RecordNotFound])
257
- .with_fallback { render(nothing: true, status: :service_unavailable) }
260
+ .with_fallback do |error|
261
+ Rails.logger.error(error)
262
+ render(nothing: true, status: :service_unavailable)
263
+ end
258
264
  .run
259
265
  end
260
266
  end
@@ -269,16 +275,14 @@ override the default behavior. You can lock a light in either the green or red
269
275
  state using `set_state`.
270
276
 
271
277
  ``` irb
272
- >> light = Stoplight::Light.new('example-8') { true }
273
- => #<Stoplight::Light:...>
278
+ >> light = Stoplight::Light.new('example-7') { true }
279
+ => #<Stoplight::Light:..>
274
280
  >> light.run
275
281
  => true
276
- >> Stoplight.data_store.set_state(
277
- .. light.name, Stoplight::DataStore::STATE_LOCKED_RED)
282
+ >> light.data_store.set_state(light, Stoplight::State::LOCKED_RED)
278
283
  => "locked_red"
279
284
  >> light.run
280
- Switching example-8 from green to red
281
- Stoplight::Error::RedLight: example-8
285
+ Stoplight::Error::RedLight: example-7
282
286
  ```
283
287
 
284
288
  **Code in locked red lights may still run under certain conditions!** If you
@@ -288,13 +292,13 @@ locked state of any stoplights.
288
292
 
289
293
  ## Credits
290
294
 
291
- Stoplight is brought to you by [@camdez][16] and [@tfausak][17] from
292
- [@OrgSync][18]. We were inspired by Martin Fowler's [CircuitBreaker][19]
295
+ Stoplight is brought to you by [@camdez][15] and [@tfausak][16] from
296
+ [@OrgSync][17]. We were inspired by Martin Fowler's [CircuitBreaker][18]
293
297
  article.
294
298
 
295
299
  If this gem isn't cutting it for you, there are a few alternatives, including:
296
- [circuit_b][20], [circuit_breaker][21], [simple_circuit_breaker][22], and
297
- [ya_circuit_breaker][23].
300
+ [circuit_b][19], [circuit_breaker][20], [simple_circuit_breaker][21], and
301
+ [ya_circuit_breaker][22].
298
302
 
299
303
  [1]: https://github.com/orgsync/stoplight
300
304
  [2]: https://badge.fury.io/rb/stoplight.svg
@@ -308,14 +312,13 @@ If this gem isn't cutting it for you, there are a few alternatives, including:
308
312
  [10]: https://gemnasium.com/orgsync/stoplight.svg
309
313
  [11]: https://gemnasium.com/orgsync/stoplight
310
314
  [12]: https://github.com/orgsync/stoplight-admin
311
- [13]: http://semver.org/spec/v2.0.0.html
312
- [14]: https://rubygems.org/gems/redis
313
- [15]: https://rubygems.org/gems/hipchat
314
- [16]: https://github.com/camdez
315
- [17]: https://github.com/tfausak
316
- [18]: https://github.com/OrgSync
317
- [19]: http://martinfowler.com/bliki/CircuitBreaker.html
318
- [20]: https://github.com/alg/circuit_b
319
- [21]: https://github.com/wsargent/circuit_breaker
320
- [22]: https://github.com/soundcloud/simple_circuit_breaker
321
- [23]: https://github.com/wooga/circuit_breaker
315
+ [13]: https://rubygems.org/gems/redis
316
+ [14]: https://rubygems.org/gems/hipchat
317
+ [15]: https://github.com/camdez
318
+ [16]: https://github.com/tfausak
319
+ [17]: https://github.com/OrgSync
320
+ [18]: http://martinfowler.com/bliki/CircuitBreaker.html
321
+ [19]: https://github.com/alg/circuit_b
322
+ [20]: https://github.com/wsargent/circuit_breaker
323
+ [21]: https://github.com/soundcloud/simple_circuit_breaker
324
+ [22]: https://github.com/wooga/circuit_breaker
data/lib/stoplight.rb CHANGED
@@ -1,29 +1,24 @@
1
1
  # coding: utf-8
2
2
 
3
+ require 'stoplight/color'
4
+ require 'stoplight/error'
5
+ require 'stoplight/failure'
6
+ require 'stoplight/state'
7
+
3
8
  require 'stoplight/data_store'
4
9
  require 'stoplight/data_store/base'
5
10
  require 'stoplight/data_store/memory'
6
11
  require 'stoplight/data_store/redis'
7
- require 'stoplight/error'
8
- require 'stoplight/failure'
9
- require 'stoplight/light'
12
+
10
13
  require 'stoplight/notifier'
11
14
  require 'stoplight/notifier/base'
12
15
  require 'stoplight/notifier/hip_chat'
13
16
  require 'stoplight/notifier/io'
14
17
 
15
- module Stoplight
16
- # @return [Gem::Version]
17
- VERSION = Gem::Version.new('0.4.1')
18
-
19
- @data_store = DataStore::Memory.new
20
- @notifiers = [Notifier::IO.new($stderr)]
18
+ require 'stoplight/default'
21
19
 
22
- class << self
23
- # @return [DataStore::Base]
24
- attr_accessor :data_store
20
+ require 'stoplight/light/runnable'
21
+ require 'stoplight/light'
25
22
 
26
- # @return [Array<Notifier::Base>]
27
- attr_accessor :notifiers
28
- end
23
+ module Stoplight
29
24
  end
@@ -0,0 +1,9 @@
1
+ # coding: utf-8
2
+
3
+ module Stoplight
4
+ module Color
5
+ GREEN = 'green'.freeze
6
+ YELLOW = 'yellow'.freeze
7
+ RED = 'red'.freeze
8
+ end
9
+ end
@@ -2,151 +2,5 @@
2
2
 
3
3
  module Stoplight
4
4
  module DataStore
5
- KEY_PREFIX = 'stoplight'.freeze
6
-
7
- COLOR_GREEN = 'green'.freeze
8
- COLOR_YELLOW = 'yellow'.freeze
9
- COLOR_RED = 'red'.freeze
10
- COLORS = Set.new([
11
- COLOR_GREEN,
12
- COLOR_YELLOW,
13
- COLOR_RED
14
- ]).freeze
15
-
16
- STATE_UNLOCKED = 'unlocked'.freeze
17
- STATE_LOCKED_GREEN = 'locked_green'.freeze
18
- STATE_LOCKED_RED = 'locked_red'.freeze
19
- STATES = Set.new([
20
- STATE_UNLOCKED,
21
- STATE_LOCKED_GREEN,
22
- STATE_LOCKED_RED
23
- ]).freeze
24
-
25
- DEFAULT_ATTEMPTS = 0
26
- DEFAULT_FAILURES = []
27
- DEFAULT_STATE = STATE_UNLOCKED
28
- DEFAULT_THRESHOLD = 3
29
- DEFAULT_TIMEOUT = 60
30
-
31
- module_function
32
-
33
- # @group Colors
34
-
35
- # @param state [String]
36
- # @param threshold [Integer]
37
- # @param failures [Array<Failure>]
38
- # @param timeout [Integer]
39
- # @return [String]
40
- def colorize(state, threshold, failures, timeout)
41
- case
42
- when state == STATE_LOCKED_GREEN then COLOR_GREEN
43
- when state == STATE_LOCKED_RED then COLOR_RED
44
- when failures.size < threshold then COLOR_GREEN
45
- when Time.now - failures.last.time > timeout then COLOR_YELLOW
46
- else COLOR_RED
47
- end
48
- end
49
-
50
- # @group Validation
51
-
52
- # @param color [String]
53
- # @raise [ArgumentError]
54
- def validate_color!(color)
55
- return if valid_color?(color)
56
- fail Error::InvalidColor, color.inspect
57
- end
58
-
59
- # @param color [String]
60
- # @return [Boolean]
61
- def valid_color?(color)
62
- COLORS.include?(color)
63
- end
64
-
65
- # @param failure [Failure]
66
- # @raise [ArgumentError]
67
- def validate_failure!(failure)
68
- return if valid_failure?(failure)
69
- fail Error::InvalidFailure, failure.inspect
70
- end
71
-
72
- # @param failure [Failure]
73
- # @return [Boolean]
74
- def valid_failure?(failure)
75
- failure.is_a?(Failure)
76
- end
77
-
78
- # @param state [String]
79
- # @raise [ArgumentError]
80
- def validate_state!(state)
81
- return if valid_state?(state)
82
- fail Error::InvalidState, state.inspect
83
- end
84
-
85
- # @param state [String]
86
- # @return [Boolean]
87
- def valid_state?(state)
88
- STATES.include?(state)
89
- end
90
-
91
- # @param threshold [Integer]
92
- # @raise [ArgumentError]
93
- def validate_threshold!(threshold)
94
- return if valid_threshold?(threshold)
95
- fail Error::InvalidThreshold, threshold.inspect
96
- end
97
-
98
- # @param threshold [Integer]
99
- # @return [Boolean]
100
- def valid_threshold?(threshold)
101
- threshold.is_a?(Integer) && threshold > 0
102
- end
103
-
104
- # @param timeout [Integer]
105
- # @raise [ArgumentError]
106
- def validate_timeout!(timeout)
107
- return if valid_timeout?(timeout)
108
- fail Error::InvalidTimeout, timeout.inspect
109
- end
110
-
111
- # @param timeout [Integer]
112
- # @return [Boolean]
113
- def valid_timeout?(timeout)
114
- timeout.is_a?(Integer)
115
- end
116
-
117
- # @group Keys
118
-
119
- # @return (see #key)
120
- def attempts_key
121
- key('attempts')
122
- end
123
-
124
- # @param name [String]
125
- # @return (see #key)
126
- def failures_key(name)
127
- key('failures', name)
128
- end
129
-
130
- # @return (see #key)
131
- def states_key
132
- key('states')
133
- end
134
-
135
- # @return (see #key)
136
- def thresholds_key
137
- key('thresholds')
138
- end
139
-
140
- # @return (see #key)
141
- def timeouts_key
142
- key('timeouts')
143
- end
144
-
145
- # @param slug [String]
146
- # @param suffix [String, nil]
147
- # @return [String]
148
- def key(slug, suffix = nil)
149
- [KEY_PREFIX, slug, suffix].compact.join(':')
150
- end
151
5
  end
152
6
  end