startback 2.1.0 → 2.1.2
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/CHANGELOG.md +92 -0
- data/lib/startback/event/bus/bunny/async.rb +72 -5
- data/lib/startback/version.rb +1 -1
- data/lib/startback/web/auto_caching.rb +36 -7
- data/lib/startback/web/ignore_content_type_without_body.rb +48 -0
- data/spec/unit/event/bus/bunny/test_async.rb +99 -0
- data/spec/unit/web/test_auto_caching.rb +58 -0
- data/spec/unit/web/test_ignore_content_type_without_body.rb +91 -0
- 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: 8d29c7de7c7ebfb8c5cc68d73da62108b88cee7e24f0e59712601ed8ca8d776f
|
|
4
|
+
data.tar.gz: 4b3e850a0465313739cb45662983e6af17edeb3dce768f2dcb4a9227085a8362
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 2f6b32627f15af41212ec13844db638f0d78bb5786d5151e6b0fab86b522ae294ede73bffb6cf2aff839f805eac9c85a9a6dde70a723f35d3b8da35124019c6d
|
|
7
|
+
data.tar.gz: 0070c3a2dc460fe286090ebaf0428a8697b2d0429df770542eda1b5c9f69e6c733caad05e230ab6be5d12ac75967a37be35b7264c28d3fc1f05da0c5f003e328
|
data/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,95 @@
|
|
|
1
|
+
## 2.1.2 - 2026-10-27
|
|
2
|
+
|
|
3
|
+
A crawler could take a page down for everyone. **AutoCaching's fix needs
|
|
4
|
+
nothing from you**; the second half is a middleware to mount where it helps.
|
|
5
|
+
|
|
6
|
+
### An error response is never made cacheable
|
|
7
|
+
|
|
8
|
+
AutoCaching stamped its production policy -- `public, must-revalidate,
|
|
9
|
+
max-age=3600, s-max-age=3600` by default -- on every response that carried
|
|
10
|
+
none of its own, a 4xx or a 5xx included. Whether a request is answered with
|
|
11
|
+
an error usually depends on that very request: a header, a cookie, a
|
|
12
|
+
credential. A cache in front of the application keys on the url alone, so one
|
|
13
|
+
client's error was handed to everyone who asked for the url next, for an
|
|
14
|
+
hour. Seen in the wild on a marketing site's home page, 400ing for every
|
|
15
|
+
visitor behind an nginx `proxy_cache` because a crawler had asked once with a
|
|
16
|
+
header it had no business sending.
|
|
17
|
+
|
|
18
|
+
A response of status 400 and up now carries `no-store`, in development and in
|
|
19
|
+
production alike. `no-store` rather than no header at all, because 404 and
|
|
20
|
+
410 are heuristically cacheable (RFC 9111) and a shared cache may keep them
|
|
21
|
+
with nothing else to go by. An application that sets its own `Cache-Control`
|
|
22
|
+
on an error still wins, as it does on a success.
|
|
23
|
+
|
|
24
|
+
Configurable like the other two policies, for an application that does want
|
|
25
|
+
its errors cached:
|
|
26
|
+
|
|
27
|
+
```ruby
|
|
28
|
+
use Startback::Web::AutoCaching, error: "public, max-age=60"
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
or through `STARTBACK_AUTOCACHING_ERROR_CACHE_CONTROL`.
|
|
32
|
+
|
|
33
|
+
### `Web::IgnoreContentTypeWithoutBody`
|
|
34
|
+
|
|
35
|
+
What produced that 400 in the first place: a `Content-Type:
|
|
36
|
+
multipart/form-data; boundary=...` on a plain GET makes Rack parse a form out
|
|
37
|
+
of a body that is not there and raise `Rack::Multipart::EmptyContentError`,
|
|
38
|
+
which Sinatra turns into a 400 before the route is ever reached. The url
|
|
39
|
+
answers an error that has nothing to do with what it serves, and crawlers
|
|
40
|
+
send that header on GETs all day. Rack is inconsistent rather than strict
|
|
41
|
+
here: the same request with `Content-Length: 0` parses to no parameters at
|
|
42
|
+
all.
|
|
43
|
+
|
|
44
|
+
The new middleware drops the content type of a GET or a HEAD, so the route
|
|
45
|
+
runs and the page is served:
|
|
46
|
+
|
|
47
|
+
```ruby
|
|
48
|
+
use Startback::Web::IgnoreContentTypeWithoutBody
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Opt-in, like every middleware here. An application with a GET route that
|
|
52
|
+
loads a body -- `Web::Api#loaded_body` dispatches on the request's content
|
|
53
|
+
type -- would see that body become unsupported. A GET that reads a content
|
|
54
|
+
type at all is unusual, content negotiation being `Accept`'s job.
|
|
55
|
+
|
|
56
|
+
## 2.1.1 - 2026-10-23
|
|
57
|
+
|
|
58
|
+
The Bunny event bus keeps consuming across a broker restart. **Nothing to do
|
|
59
|
+
to get the fix**; `alive?` is there for whoever wants a liveness probe.
|
|
60
|
+
|
|
61
|
+
### Listeners survive a broker restart
|
|
62
|
+
|
|
63
|
+
2.1.0 adopts a pre-existing exchange or queue passively when its properties
|
|
64
|
+
differ from the requested ones -- what an older Startback left behind, a
|
|
65
|
+
transient topology. Bunny does not record passive declarations for topology
|
|
66
|
+
recovery. After a broker restart the transient objects are gone, Bunny's
|
|
67
|
+
replay of the queue binding fails with `NOT_FOUND`, the broker closes the
|
|
68
|
+
channel the consumer lived on, and the application keeps running without
|
|
69
|
+
consuming anything, with nothing louder than an error line in Bunny's log.
|
|
70
|
+
It took a manual restart to get it back.
|
|
71
|
+
|
|
72
|
+
Once Bunny reports the connection recovered, the bus now forgets what it
|
|
73
|
+
knew of the topology and subscribes again every listener whose consumer did
|
|
74
|
+
not come back, declaring the exchange and queue as requested -- durable,
|
|
75
|
+
since the transient ones are gone. Listeners whose consumer survived are left
|
|
76
|
+
alone. An application's own `recovery_completed` connection option is still
|
|
77
|
+
called, first.
|
|
78
|
+
|
|
79
|
+
### `Bus::Bunny::Async#alive?`
|
|
80
|
+
|
|
81
|
+
True when the bus is connected and every listener still has a consumer on an
|
|
82
|
+
open channel. A recovery that fails to subscribe a listener again leaves it
|
|
83
|
+
dead and logs the error; Bunny's own recovery may also never complete at all.
|
|
84
|
+
`alive?` reports both, which makes it the right thing for a liveness probe to
|
|
85
|
+
check:
|
|
86
|
+
|
|
87
|
+
```ruby
|
|
88
|
+
map '/health/bus' do
|
|
89
|
+
run ->(_env) { bus.async.alive? ? [204, {}, []] : [503, {}, ["Bus is not consuming"]] }
|
|
90
|
+
end
|
|
91
|
+
```
|
|
92
|
+
|
|
1
93
|
## 2.1.0 - 2026-09-30
|
|
2
94
|
|
|
3
95
|
Ruby 4.0 support, docker images for several ruby versions, and the Bunny
|
|
@@ -28,6 +28,15 @@ module Startback
|
|
|
28
28
|
# A warning is logged, and the durable declaration takes effect on its
|
|
29
29
|
# own at the next broker restart, transient objects not surviving one.
|
|
30
30
|
#
|
|
31
|
+
# Listeners **survive a broker restart**. Bunny recovers the topology
|
|
32
|
+
# it recorded, but not what was adopted passively: rebinding such a
|
|
33
|
+
# queue fails with NOT_FOUND and closes the channel its consumer lives
|
|
34
|
+
# on. Once Bunny reports the connection recovered, the bus therefore
|
|
35
|
+
# subscribes again every listener whose consumer did not come back.
|
|
36
|
+
# `alive?` tells whether every listener still consumes, which is what
|
|
37
|
+
# a liveness probe wants to ask: Bunny's own recovery may also never
|
|
38
|
+
# complete at all.
|
|
39
|
+
#
|
|
31
40
|
# Examples:
|
|
32
41
|
#
|
|
33
42
|
# # Connects to RabbitMQ using all default options
|
|
@@ -102,6 +111,8 @@ module Startback
|
|
|
102
111
|
@options = DEFAULT_OPTIONS.merge(options)
|
|
103
112
|
@topology = {}
|
|
104
113
|
@topology_lock = Mutex.new
|
|
114
|
+
@listeners = []
|
|
115
|
+
@listeners_lock = Mutex.new
|
|
105
116
|
connect if @options[:autoconnect]
|
|
106
117
|
end
|
|
107
118
|
attr_reader :options
|
|
@@ -114,6 +125,7 @@ module Startback
|
|
|
114
125
|
conn = options[:connection_options] || options[:url]
|
|
115
126
|
try_max_times(10) do
|
|
116
127
|
@bunny = ::Bunny.new(conn)
|
|
128
|
+
@bunny.after_recovery_completed { recovery_completed(conn) }
|
|
117
129
|
@bunny.start
|
|
118
130
|
channel # make sure we already create the channel
|
|
119
131
|
log(:info, {op: "#{self.class.name}#connect", op_data: conn}, options[:context])
|
|
@@ -132,6 +144,13 @@ module Startback
|
|
|
132
144
|
@bunny && @bunny.connected?
|
|
133
145
|
end
|
|
134
146
|
|
|
147
|
+
# Whether the bus is connected and every listener still consumes:
|
|
148
|
+
# its channel is open and the broker did not cancel its consumer.
|
|
149
|
+
# False is what a liveness probe should restart the process on.
|
|
150
|
+
def alive?
|
|
151
|
+
!!connected? && listeners.all?(&:alive?)
|
|
152
|
+
end
|
|
153
|
+
|
|
135
154
|
def channel
|
|
136
155
|
unless @bunny
|
|
137
156
|
raise Startback::Errors::Error, "Please connect your bus first, or use autoconnect: true"
|
|
@@ -163,19 +182,67 @@ module Startback
|
|
|
163
182
|
def listen(type, processor = nil, listener = nil, &bl)
|
|
164
183
|
raise ArgumentError, "A listener must be provided" unless listener || bl
|
|
165
184
|
|
|
166
|
-
|
|
167
|
-
|
|
185
|
+
subscription = Subscription.new(type.to_s, (processor || "main").to_s, listener || bl)
|
|
186
|
+
subscribe(subscription)
|
|
187
|
+
@listeners_lock.synchronize { @listeners << subscription }
|
|
188
|
+
subscription.consumer
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
protected
|
|
192
|
+
|
|
193
|
+
# What `listen` was asked for, kept to subscribe again after a
|
|
194
|
+
# broker restart, along with the consumer currently serving it.
|
|
195
|
+
class Subscription < Struct.new(:type, :processor, :listener, :consumer)
|
|
196
|
+
def alive?
|
|
197
|
+
channel = consumer && consumer.channel
|
|
198
|
+
!!channel && channel.open? && channel.consumers.key?(consumer.consumer_tag)
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def listeners
|
|
203
|
+
@listeners_lock.synchronize { @listeners.dup }
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def subscribe(subscription)
|
|
207
|
+
fanout = declare(:fanout, subscription.type, fanout_options)
|
|
208
|
+
queue = declare(:queue, subscription.processor, queue_options)
|
|
168
209
|
# Bound by name on purpose: declaring the queue may have renewed
|
|
169
210
|
# the channel, in which case `fanout` belongs to a closed one.
|
|
170
211
|
queue.bind(fanout.name)
|
|
171
|
-
queue.subscribe do |delivery_info, properties, body|
|
|
212
|
+
subscription.consumer = queue.subscribe do |delivery_info, properties, body|
|
|
172
213
|
stop_errors(self, "listen") do
|
|
173
|
-
|
|
214
|
+
subscription.listener.call(body)
|
|
174
215
|
end
|
|
175
216
|
end
|
|
176
217
|
end
|
|
177
218
|
|
|
178
|
-
|
|
219
|
+
# Called by Bunny once it reconnected and replayed the topology it
|
|
220
|
+
# recorded, after the application's own `recovery_completed`
|
|
221
|
+
# callback, if any.
|
|
222
|
+
#
|
|
223
|
+
# What Bunny replayed may no longer match the broker -- objects
|
|
224
|
+
# adopted passively are not replayed at all -- so the topology is
|
|
225
|
+
# probed anew, and the listeners left without a consumer subscribe
|
|
226
|
+
# again. One that fails stays dead, and `alive?` reports it.
|
|
227
|
+
def recovery_completed(conn)
|
|
228
|
+
conn[:recovery_completed].call if conn.is_a?(Hash) && conn[:recovery_completed]
|
|
229
|
+
|
|
230
|
+
@topology_lock.synchronize { @topology = {} }
|
|
231
|
+
listeners.reject(&:alive?).each do |subscription|
|
|
232
|
+
renew_channel!
|
|
233
|
+
subscribe(subscription)
|
|
234
|
+
log(:info, {
|
|
235
|
+
op: "#{self.class.name}#recovery_completed",
|
|
236
|
+
op_data: { type: subscription.type, processor: subscription.processor, msg: "Subscribed again" }
|
|
237
|
+
}, options[:context])
|
|
238
|
+
rescue StandardError => ex
|
|
239
|
+
log(:error, {
|
|
240
|
+
op: "#{self.class.name}#recovery_completed",
|
|
241
|
+
op_data: { type: subscription.type, processor: subscription.processor },
|
|
242
|
+
error: ex
|
|
243
|
+
}, options[:context])
|
|
244
|
+
end
|
|
245
|
+
end
|
|
179
246
|
|
|
180
247
|
# Declares an exchange (`kind` = :fanout) or a queue (:queue) with
|
|
181
248
|
# `options`, adopting one that already exists with other properties
|
data/lib/startback/version.rb
CHANGED
|
@@ -6,9 +6,18 @@ module Startback
|
|
|
6
6
|
# This rack middleware automatically mark response as non being cacheable
|
|
7
7
|
# in development, and being cacheble in production.
|
|
8
8
|
#
|
|
9
|
-
#
|
|
10
|
-
#
|
|
11
|
-
#
|
|
9
|
+
# An error response is never made cacheable, in either mode. Whether a
|
|
10
|
+
# request is answered 4xx usually depends on that very request -- a header,
|
|
11
|
+
# a cookie, a credential -- while a cache in front of the application keys
|
|
12
|
+
# on the url alone, so one client's error would otherwise be served to
|
|
13
|
+
# everyone asking for the url next. It is marked `no-store` rather than
|
|
14
|
+
# merely left alone, because 404 and 410 are heuristically cacheable
|
|
15
|
+
# (RFC 9111) and a shared cache may keep them with no header to go by.
|
|
16
|
+
#
|
|
17
|
+
# The headers to set in development, in production and on an error can be
|
|
18
|
+
# passed at construction, as well as whether the development environment
|
|
19
|
+
# must be forced. This class may also be configured through environment
|
|
20
|
+
# variables:
|
|
12
21
|
#
|
|
13
22
|
# - RACK_ENV: when "production" use the production headers, otherwise use
|
|
14
23
|
# the development ones
|
|
@@ -16,6 +25,8 @@ module Startback
|
|
|
16
25
|
# to use in development mode
|
|
17
26
|
# - STARTBACK_AUTOCACHING_PRODUCTION_CACHE_CONTROL: Cache-Control header
|
|
18
27
|
# to use in production mode
|
|
28
|
+
# - STARTBACK_AUTOCACHING_ERROR_CACHE_CONTROL: Cache-Control header to use
|
|
29
|
+
# on a response of status 400 and up
|
|
19
30
|
#
|
|
20
31
|
# Example:
|
|
21
32
|
#
|
|
@@ -31,6 +42,9 @@ module Startback
|
|
|
31
42
|
# # Set production headers manually
|
|
32
43
|
# use Autocaching, { :production => "public, no-cache, no-store" }
|
|
33
44
|
#
|
|
45
|
+
# # Let a shared cache keep a not found for a minute
|
|
46
|
+
# use Autocaching, { :error => "public, max-age=60" }
|
|
47
|
+
#
|
|
34
48
|
class AutoCaching
|
|
35
49
|
|
|
36
50
|
# Cache-Control header to use in development mode
|
|
@@ -41,6 +55,13 @@ module Startback
|
|
|
41
55
|
PRODUCTION_CACHE_CONTROL = ENV['STARTBACK_AUTOCACHING_PRODUCTION_CACHE_CONTROL'] ||\
|
|
42
56
|
"public, must-revalidate, max-age=3600, s-max-age=3600"
|
|
43
57
|
|
|
58
|
+
# Cache-Control header to use on an error response, in either mode
|
|
59
|
+
ERROR_CACHE_CONTROL = ENV['STARTBACK_AUTOCACHING_ERROR_CACHE_CONTROL'] ||\
|
|
60
|
+
"no-store"
|
|
61
|
+
|
|
62
|
+
# Lowest status this middleware considers an error
|
|
63
|
+
ERROR_STATUS = 400
|
|
64
|
+
|
|
44
65
|
def initialize(app, development = nil, cache_headers = {})
|
|
45
66
|
development, cache_headers = nil, development if development.is_a?(Hash)
|
|
46
67
|
@app = app
|
|
@@ -50,7 +71,7 @@ module Startback
|
|
|
50
71
|
|
|
51
72
|
def call(env)
|
|
52
73
|
status, headers, body = @app.call(env)
|
|
53
|
-
[status, patch_response_headers(headers), body]
|
|
74
|
+
[status, patch_response_headers(status, headers), body]
|
|
54
75
|
end
|
|
55
76
|
|
|
56
77
|
protected
|
|
@@ -58,9 +79,14 @@ module Startback
|
|
|
58
79
|
# Rack::Headers is used so that the defaults set here are actually
|
|
59
80
|
# overriden by the downstream application, whatever the case it uses
|
|
60
81
|
# for its own header names.
|
|
61
|
-
def patch_response_headers(hs)
|
|
62
|
-
|
|
63
|
-
|
|
82
|
+
def patch_response_headers(status, hs)
|
|
83
|
+
Rack::Headers[defaults_for(status)].merge(hs)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def defaults_for(status)
|
|
87
|
+
return @cache_headers[:error] if status >= ERROR_STATUS
|
|
88
|
+
|
|
89
|
+
development? ? @cache_headers[:development] : @cache_headers[:production]
|
|
64
90
|
end
|
|
65
91
|
|
|
66
92
|
def development?
|
|
@@ -78,6 +104,9 @@ module Startback
|
|
|
78
104
|
},
|
|
79
105
|
production: {
|
|
80
106
|
"cache-control" => PRODUCTION_CACHE_CONTROL
|
|
107
|
+
},
|
|
108
|
+
error: {
|
|
109
|
+
"cache-control" => ERROR_CACHE_CONTROL
|
|
81
110
|
}
|
|
82
111
|
}
|
|
83
112
|
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
require 'rack'
|
|
2
|
+
|
|
3
|
+
module Startback
|
|
4
|
+
module Web
|
|
5
|
+
#
|
|
6
|
+
# This Rack middleware drops the content type of a request that carries no
|
|
7
|
+
# body, that is, of a GET or a HEAD.
|
|
8
|
+
#
|
|
9
|
+
# A `Content-Type: multipart/form-data; boundary=...` on a GET makes Rack
|
|
10
|
+
# parse a form out of a body that is not there and raise
|
|
11
|
+
# `Rack::Multipart::EmptyContentError`, which Sinatra turns into a 400
|
|
12
|
+
# before the route is ever reached. The answer therefore has nothing to do
|
|
13
|
+
# with what the url serves, and crawlers send exactly that header. Rack is
|
|
14
|
+
# inconsistent here rather than strict: the same request with
|
|
15
|
+
# `Content-Length: 0` parses to no parameters at all.
|
|
16
|
+
#
|
|
17
|
+
# Mount it on an application whose GETs and HEADs ignore the request's
|
|
18
|
+
# content type, which is the usual case -- content negotiation reads
|
|
19
|
+
# `Accept`. An application with a GET route that loads a body (see
|
|
20
|
+
# `Startback::Web::Api#loaded_body`) would see that body become
|
|
21
|
+
# unsupported, hence the opt-in.
|
|
22
|
+
#
|
|
23
|
+
# Examples:
|
|
24
|
+
#
|
|
25
|
+
# Rack::Builder.new do
|
|
26
|
+
# use Startback::Web::IgnoreContentTypeWithoutBody
|
|
27
|
+
# end
|
|
28
|
+
#
|
|
29
|
+
class IgnoreContentTypeWithoutBody
|
|
30
|
+
|
|
31
|
+
BODYLESS_METHODS = %w[GET HEAD]
|
|
32
|
+
|
|
33
|
+
def self.ignore_content_type?(env)
|
|
34
|
+
BODYLESS_METHODS.include?(env["REQUEST_METHOD"]) && env.key?("CONTENT_TYPE")
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def initialize(app)
|
|
38
|
+
@app = app
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def call(env)
|
|
42
|
+
env.delete("CONTENT_TYPE") if self.class.ignore_content_type?(env)
|
|
43
|
+
@app.call(env)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
end # class IgnoreContentTypeWithoutBody
|
|
47
|
+
end # module Web
|
|
48
|
+
end # module Startback
|
|
@@ -259,6 +259,105 @@ module Startback
|
|
|
259
259
|
|
|
260
260
|
end
|
|
261
261
|
|
|
262
|
+
describe "staying alive" do
|
|
263
|
+
|
|
264
|
+
# What a broker restart leaves behind when the queue was adopted
|
|
265
|
+
# passively: Bunny does not replay it, rebinding fails, and the
|
|
266
|
+
# consumer is gone. Deleting the queue from elsewhere gets the same
|
|
267
|
+
# outcome -- the broker cancels the consumer -- without a restart.
|
|
268
|
+
def kill_consumer!
|
|
269
|
+
BunnyBroker.delete_topology(queues: [processor])
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
# Bunny calls this once it reconnected and replayed its topology.
|
|
273
|
+
def complete_recovery!(b)
|
|
274
|
+
b.instance_variable_get(:@bunny).send(:notify_of_recovery_completion)
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
it 'is not alive before connecting' do
|
|
278
|
+
expect(bus.alive?).to eql(false)
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
it 'is alive once connected and listening' do
|
|
282
|
+
b = connected_bus
|
|
283
|
+
b.listen(type, processor) { |body| }
|
|
284
|
+
expect(b.alive?).to eql(true)
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
it 'is not alive once disconnected' do
|
|
288
|
+
b = connected_bus
|
|
289
|
+
b.listen(type, processor) { |body| }
|
|
290
|
+
b.disconnect
|
|
291
|
+
expect(b.alive?).to eql(false)
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
it 'is not alive when a consumer is gone' do
|
|
295
|
+
b = connected_bus
|
|
296
|
+
b.listen(type, processor) { |body| }
|
|
297
|
+
kill_consumer!
|
|
298
|
+
|
|
299
|
+
expect(BunnyBroker.wait_for { !b.alive? }).to eql(true)
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
it 'subscribes dead listeners again once a recovery completed' do
|
|
303
|
+
b = connected_bus
|
|
304
|
+
seen = Queue.new
|
|
305
|
+
b.listen(type, processor) { |body| seen << body }
|
|
306
|
+
kill_consumer!
|
|
307
|
+
BunnyBroker.wait_for { !b.alive? }
|
|
308
|
+
|
|
309
|
+
complete_recovery!(b)
|
|
310
|
+
|
|
311
|
+
expect(b.alive?).to eql(true)
|
|
312
|
+
b.emit(Event.new(type, { id: 12 }))
|
|
313
|
+
received = BunnyBroker.wait_for { seen.pop unless seen.empty? }
|
|
314
|
+
expect(received).not_to be_nil
|
|
315
|
+
expect(JSON.parse(received)["data"]).to eql({ "id" => 12 })
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
it 'declares the topology as requested when subscribing again' do
|
|
319
|
+
# An exchange adopted passively before the restart is gone after
|
|
320
|
+
# it: the durable declaration must win then, not the memoized
|
|
321
|
+
# `passive: true`, which would fail with NOT_FOUND.
|
|
322
|
+
b = connected_bus
|
|
323
|
+
b.listen(type, processor) { |body| }
|
|
324
|
+
b.instance_variable_get(:@topology)[[:fanout, type]] = { passive: true }
|
|
325
|
+
BunnyBroker.delete_topology(exchanges: [type], queues: [processor])
|
|
326
|
+
BunnyBroker.wait_for { !b.alive? }
|
|
327
|
+
|
|
328
|
+
complete_recovery!(b)
|
|
329
|
+
|
|
330
|
+
expect(b.alive?).to eql(true)
|
|
331
|
+
expect {
|
|
332
|
+
ch = b.channel
|
|
333
|
+
ch.fanout(type, durable: true, passive: true)
|
|
334
|
+
ch.queue(processor, durable: true, passive: true)
|
|
335
|
+
}.not_to raise_error
|
|
336
|
+
end
|
|
337
|
+
|
|
338
|
+
it 'leaves live listeners alone' do
|
|
339
|
+
b = connected_bus
|
|
340
|
+
consumer = b.listen(type, processor) { |body| }
|
|
341
|
+
|
|
342
|
+
complete_recovery!(b)
|
|
343
|
+
|
|
344
|
+
expect(b.send(:listeners).first.consumer).to equal(consumer)
|
|
345
|
+
end
|
|
346
|
+
|
|
347
|
+
it "still calls the application's own recovery_completed" do
|
|
348
|
+
called = Queue.new
|
|
349
|
+
b = connected_bus(connection_options: ::Bunny::Session.parse_uri(url).merge(
|
|
350
|
+
recovery_completed: -> { called << true }
|
|
351
|
+
))
|
|
352
|
+
b.listen(type, processor) { |body| }
|
|
353
|
+
|
|
354
|
+
complete_recovery!(b)
|
|
355
|
+
|
|
356
|
+
expect(called.empty?).to eql(false)
|
|
357
|
+
end
|
|
358
|
+
|
|
359
|
+
end
|
|
360
|
+
|
|
262
361
|
describe "the asynchronous contract" do
|
|
263
362
|
|
|
264
363
|
it 'hides emit errors from the emitter' do
|
|
@@ -62,6 +62,64 @@ module Startback
|
|
|
62
62
|
end
|
|
63
63
|
end
|
|
64
64
|
|
|
65
|
+
context 'when the application answers an error in production' do
|
|
66
|
+
def cache_control_for(status)
|
|
67
|
+
app = Rack::Builder.new do
|
|
68
|
+
use AutoCaching, false
|
|
69
|
+
run ->(env){ [status, {}, ["Hello error"]] }
|
|
70
|
+
end.to_app
|
|
71
|
+
_, headers, _ = app.call(Rack::MockRequest.env_for("/"))
|
|
72
|
+
headers["cache-control"]
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# A cache in front of the application keys on the url, while the error
|
|
76
|
+
# is about this request; the production policy would hand it to every
|
|
77
|
+
# later visitor of the url.
|
|
78
|
+
it 'forbids storing a bad request' do
|
|
79
|
+
expect(cache_control_for(400)).to eql("no-store")
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
it 'forbids storing a not found, heuristically cacheable otherwise' do
|
|
83
|
+
expect(cache_control_for(404)).to eql("no-store")
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
it 'forbids storing a server error' do
|
|
87
|
+
expect(cache_control_for(500)).to eql("no-store")
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
it 'still makes a redirect cacheable' do
|
|
91
|
+
expect(cache_control_for(301)).to eql("public, must-revalidate, max-age=3600, s-max-age=3600")
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
context 'when the error policy is set manually' do
|
|
96
|
+
def app
|
|
97
|
+
Rack::Builder.new do
|
|
98
|
+
use AutoCaching, false, error: "public, max-age=60"
|
|
99
|
+
run ->(env){ [404, {}, ["Hello error"]] }
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
it 'uses it rather than the default one' do
|
|
104
|
+
get '/'
|
|
105
|
+
expect(last_response['Cache-Control']).to eql("public, max-age=60")
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
context 'when the application sets its own Cache-Control on an error' do
|
|
110
|
+
def app
|
|
111
|
+
Rack::Builder.new do
|
|
112
|
+
use AutoCaching, false
|
|
113
|
+
run ->(env){ [404, {"cache-control" => "public, max-age=60"}, ["Hello error"]] }
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
it 'lets the application win, as on a success' do
|
|
118
|
+
get '/'
|
|
119
|
+
expect(last_response['Cache-Control']).to eql("public, max-age=60")
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
65
123
|
context 'when a Cache-Control header is already set by the app' do
|
|
66
124
|
def app
|
|
67
125
|
Rack::Builder.new do
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
require 'spec_helper'
|
|
2
|
+
require 'sinatra/base'
|
|
3
|
+
require 'startback/web/ignore_content_type_without_body'
|
|
4
|
+
|
|
5
|
+
module Startback
|
|
6
|
+
module Web
|
|
7
|
+
describe IgnoreContentTypeWithoutBody do
|
|
8
|
+
include Rack::Test::Methods
|
|
9
|
+
|
|
10
|
+
MULTIPART_CTYPE = "multipart/form-data; boundary=----WebKitFormBoundaryJ2mHj"
|
|
11
|
+
|
|
12
|
+
context 'what the application sees' do
|
|
13
|
+
# Reported as a header rather than a body, so that a HEAD can be
|
|
14
|
+
# asserted on the same way as a GET.
|
|
15
|
+
def app
|
|
16
|
+
Rack::Builder.new do
|
|
17
|
+
use IgnoreContentTypeWithoutBody
|
|
18
|
+
run ->(env){ [200, { "x-seen-content-type" => env["CONTENT_TYPE"].to_s }, []] }
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def seen_content_type
|
|
23
|
+
last_response["x-seen-content-type"]
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
it 'hides the content type a GET has no business sending' do
|
|
27
|
+
get '/', {}, "CONTENT_TYPE" => MULTIPART_CTYPE
|
|
28
|
+
expect(seen_content_type).to eql("")
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
it 'hides it from a HEAD too' do
|
|
32
|
+
head '/', {}, "CONTENT_TYPE" => MULTIPART_CTYPE
|
|
33
|
+
expect(seen_content_type).to eql("")
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
it 'leaves the content type of a POST alone, body or no body' do
|
|
37
|
+
post '/', {}, "CONTENT_TYPE" => MULTIPART_CTYPE
|
|
38
|
+
expect(seen_content_type).to eql(MULTIPART_CTYPE)
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Rack only parses the form, and only raises, when a body is there to
|
|
43
|
+
# read and no content length says how long it is -- which is what a
|
|
44
|
+
# server hands over for a GET. Rack::Test provides neither on its own.
|
|
45
|
+
def get_with_bogus_content_type
|
|
46
|
+
get '/', {}, {
|
|
47
|
+
"CONTENT_TYPE" => MULTIPART_CTYPE,
|
|
48
|
+
"rack.input" => StringIO.new('')
|
|
49
|
+
}
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
class Page < Sinatra::Base
|
|
53
|
+
set :raise_errors, true
|
|
54
|
+
set :show_exceptions, false
|
|
55
|
+
|
|
56
|
+
get('/') { "hello" }
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
context 'on a sinatra application that serves a GET' do
|
|
60
|
+
def app
|
|
61
|
+
Rack::Builder.new do
|
|
62
|
+
use IgnoreContentTypeWithoutBody
|
|
63
|
+
run Page
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
it 'serves the page instead of raising on the absent body' do
|
|
68
|
+
get_with_bogus_content_type
|
|
69
|
+
expect(last_response.status).to eql(200)
|
|
70
|
+
expect(last_response.body).to eql("hello")
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
context 'on the same application, unprotected' do
|
|
75
|
+
def app
|
|
76
|
+
Page
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Which is what this middleware exists for. The day Rack stops telling
|
|
80
|
+
# an empty multipart body apart from a missing one, this fails and the
|
|
81
|
+
# middleware can go.
|
|
82
|
+
it 'answers a 400 that has nothing to do with the url' do
|
|
83
|
+
get_with_bogus_content_type
|
|
84
|
+
expect(last_response.status).to eql(400)
|
|
85
|
+
expect(last_response.body).to include("Invalid multipart/form-data")
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
end # IgnoreContentTypeWithoutBody
|
|
90
|
+
end # module Web
|
|
91
|
+
end # module Startback
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: startback
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 2.1.
|
|
4
|
+
version: 2.1.2
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Bernard Lambeau
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-
|
|
11
|
+
date: 2026-10-27 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: rspec
|
|
@@ -529,6 +529,7 @@ files:
|
|
|
529
529
|
- lib/startback/web/catch_all.rb
|
|
530
530
|
- lib/startback/web/cors_headers.rb
|
|
531
531
|
- lib/startback/web/health_check.rb
|
|
532
|
+
- lib/startback/web/ignore_content_type_without_body.rb
|
|
532
533
|
- lib/startback/web/middleware.rb
|
|
533
534
|
- lib/startback/web/prometheus.rb
|
|
534
535
|
- lib/startback/web/shield.rb
|
|
@@ -569,6 +570,7 @@ files:
|
|
|
569
570
|
- spec/unit/web/test_catch_all.rb
|
|
570
571
|
- spec/unit/web/test_cors_headers.rb
|
|
571
572
|
- spec/unit/web/test_healthcheck.rb
|
|
573
|
+
- spec/unit/web/test_ignore_content_type_without_body.rb
|
|
572
574
|
- tasks/test.rake
|
|
573
575
|
homepage: https://www.enspirit.be
|
|
574
576
|
licenses:
|