message_bus 2.0.2 → 2.0.3
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.
Potentially problematic release.
This version of message_bus might be problematic. Click here for more details.
- checksums.yaml +4 -4
- data/.rubocop.yml +104 -0
- data/CHANGELOG +9 -0
- data/README.md +62 -11
- data/lib/message_bus.rb +25 -25
- data/lib/message_bus/backends/memory.rb +14 -13
- data/lib/message_bus/backends/postgres.rb +34 -29
- data/lib/message_bus/backends/redis.rb +13 -15
- data/lib/message_bus/client.rb +21 -11
- data/lib/message_bus/connection_manager.rb +4 -3
- data/lib/message_bus/message.rb +7 -5
- data/lib/message_bus/rack/diagnostics.rb +5 -4
- data/lib/message_bus/rack/middleware.rb +19 -18
- data/lib/message_bus/rack/thin_ext.rb +2 -1
- data/lib/message_bus/rails/railtie.rb +16 -3
- data/lib/message_bus/timer_thread.rb +18 -14
- data/lib/message_bus/version.rb +2 -1
- data/message_bus.gemspec +2 -1
- data/spec/lib/message_bus/backends/postgres_spec.rb +1 -1
- data/spec/lib/message_bus/backends/redis_spec.rb +1 -1
- data/spec/lib/message_bus/client_spec.rb +24 -1
- data/spec/lib/message_bus/connection_manager_spec.rb +4 -4
- data/spec/lib/message_bus/rack/middleware_spec.rb +10 -2
- data/spec/lib/message_bus_spec.rb +1 -1
- data/vendor/assets/javascripts/message-bus-ajax.js +1 -0
- data/vendor/assets/javascripts/message-bus.js +1 -0
- metadata +14 -13
- data/vendor/assets/javascripts/message-bus-ajax.js +0 -44
- data/vendor/assets/javascripts/message-bus.js +0 -431
@@ -1,3 +1,4 @@
|
|
1
|
+
# frozen_string_literal: true
|
1
2
|
require 'pg'
|
2
3
|
|
3
4
|
module MessageBus::Postgres; end
|
@@ -30,37 +31,41 @@ class MessageBus::Postgres::Client
|
|
30
31
|
@pid = Process.pid
|
31
32
|
end
|
32
33
|
|
33
|
-
def add(channel, value)
|
34
|
-
hold{|conn| exec_prepared(conn, 'insert_message', [channel, value]){|r| r.getvalue(0,0).to_i}}
|
34
|
+
def add (channel, value)
|
35
|
+
hold { |conn| exec_prepared(conn, 'insert_message', [channel, value]) { |r| r.getvalue(0, 0).to_i } }
|
35
36
|
end
|
36
37
|
|
37
38
|
def clear_global_backlog(backlog_id, num_to_keep)
|
38
39
|
if backlog_id > num_to_keep
|
39
|
-
hold{|conn| exec_prepared(conn, 'clear_global_backlog', [backlog_id - num_to_keep])}
|
40
|
+
hold { |conn| exec_prepared(conn, 'clear_global_backlog', [backlog_id - num_to_keep]) }
|
40
41
|
nil
|
41
42
|
end
|
42
43
|
end
|
43
44
|
|
44
45
|
def clear_channel_backlog(channel, backlog_id, num_to_keep)
|
45
|
-
hold{|conn| exec_prepared(conn, 'clear_channel_backlog', [channel, backlog_id, num_to_keep])}
|
46
|
+
hold { |conn| exec_prepared(conn, 'clear_channel_backlog', [channel, backlog_id, num_to_keep]) }
|
46
47
|
nil
|
47
48
|
end
|
48
49
|
|
49
50
|
def expire(max_backlog_age)
|
50
|
-
hold{|conn| exec_prepared(conn, 'expire', [max_backlog_age])}
|
51
|
+
hold { |conn| exec_prepared(conn, 'expire', [max_backlog_age]) }
|
51
52
|
nil
|
52
53
|
end
|
53
54
|
|
54
55
|
def backlog(channel, backlog_id)
|
55
|
-
hold
|
56
|
+
hold do |conn|
|
57
|
+
exec_prepared(conn, 'channel_backlog', [channel, backlog_id]) { |r| r.values.each { |a| a[0] = a[0].to_i } }
|
58
|
+
end || []
|
56
59
|
end
|
57
60
|
|
58
61
|
def global_backlog(backlog_id)
|
59
|
-
hold
|
62
|
+
hold do |conn|
|
63
|
+
exec_prepared(conn, 'global_backlog', [backlog_id]) { |r| r.values.each { |a| a[0] = a[0].to_i } }
|
64
|
+
end || []
|
60
65
|
end
|
61
66
|
|
62
67
|
def get_value(channel, id)
|
63
|
-
hold{|conn| exec_prepared(conn, 'get_message', [channel, id]){|r| r.getvalue(0,0)}}
|
68
|
+
hold { |conn| exec_prepared(conn, 'get_message', [channel, id]) { |r| r.getvalue(0, 0) } }
|
64
69
|
end
|
65
70
|
|
66
71
|
def reconnect
|
@@ -78,37 +83,37 @@ class MessageBus::Postgres::Client
|
|
78
83
|
end
|
79
84
|
end
|
80
85
|
|
81
|
-
def max_id(channel=nil)
|
86
|
+
def max_id(channel = nil)
|
82
87
|
block = proc do |r|
|
83
88
|
if r.ntuples > 0
|
84
|
-
r.getvalue(0,0).to_i
|
89
|
+
r.getvalue(0, 0).to_i
|
85
90
|
else
|
86
91
|
0
|
87
92
|
end
|
88
93
|
end
|
89
94
|
|
90
95
|
if channel
|
91
|
-
hold{|conn| exec_prepared(conn, 'max_channel_id', [channel], &block)}
|
96
|
+
hold { |conn| exec_prepared(conn, 'max_channel_id', [channel], &block) }
|
92
97
|
else
|
93
|
-
hold{|conn| exec_prepared(conn, 'max_id', &block)}
|
98
|
+
hold { |conn| exec_prepared(conn, 'max_id', &block) }
|
94
99
|
end
|
95
100
|
end
|
96
101
|
|
97
102
|
def publish(channel, data)
|
98
|
-
hold{|conn| exec_prepared(conn, 'publish', [channel, data])}
|
103
|
+
hold { |conn| exec_prepared(conn, 'publish', [channel, data]) }
|
99
104
|
end
|
100
105
|
|
101
106
|
def subscribe(channel)
|
102
107
|
obj = Object.new
|
103
|
-
sync{@listening_on[channel] = obj}
|
108
|
+
sync { @listening_on[channel] = obj }
|
104
109
|
listener = Listener.new
|
105
110
|
yield listener
|
106
|
-
|
111
|
+
|
107
112
|
conn = raw_pg_connection
|
108
113
|
conn.exec "LISTEN #{channel}"
|
109
114
|
listener.do_sub.call
|
110
115
|
while listening_on?(channel, obj)
|
111
|
-
conn.wait_for_notify(10) do |_,_,payload|
|
116
|
+
conn.wait_for_notify(10) do |_, _, payload|
|
112
117
|
break unless listening_on?(channel, obj)
|
113
118
|
listener.do_message.call(nil, payload)
|
114
119
|
end
|
@@ -120,7 +125,7 @@ class MessageBus::Postgres::Client
|
|
120
125
|
end
|
121
126
|
|
122
127
|
def unsubscribe
|
123
|
-
sync{@listening_on.clear}
|
128
|
+
sync { @listening_on.clear }
|
124
129
|
end
|
125
130
|
|
126
131
|
private
|
@@ -149,22 +154,22 @@ class MessageBus::Postgres::Client
|
|
149
154
|
end
|
150
155
|
end
|
151
156
|
|
152
|
-
if conn = sync{@allocated[Thread.current]}
|
157
|
+
if conn = sync { @allocated[Thread.current] }
|
153
158
|
return yield(conn)
|
154
159
|
end
|
155
|
-
|
160
|
+
|
156
161
|
begin
|
157
|
-
conn = sync{@available.shift} || new_pg_connection
|
158
|
-
sync{@allocated[Thread.current] = conn}
|
162
|
+
conn = sync { @available.shift } || new_pg_connection
|
163
|
+
sync { @allocated[Thread.current] = conn }
|
159
164
|
yield conn
|
160
165
|
rescue PG::ConnectionBad, PG::UnableToSend => e
|
161
166
|
# don't add this connection back to the pool
|
162
167
|
ensure
|
163
|
-
sync{@allocated.delete(Thread.current)}
|
168
|
+
sync { @allocated.delete(Thread.current) }
|
164
169
|
if Process.pid != current_pid
|
165
|
-
sync{INHERITED_CONNECTIONS << conn}
|
170
|
+
sync { INHERITED_CONNECTIONS << conn }
|
166
171
|
elsif conn && !e
|
167
|
-
sync{@available << conn}
|
172
|
+
sync { @available << conn }
|
168
173
|
end
|
169
174
|
end
|
170
175
|
end
|
@@ -197,11 +202,11 @@ class MessageBus::Postgres::Client
|
|
197
202
|
end
|
198
203
|
|
199
204
|
def listening_on?(channel, obj)
|
200
|
-
sync{@listening_on[channel]} == obj
|
205
|
+
sync { @listening_on[channel] } == obj
|
201
206
|
end
|
202
207
|
|
203
208
|
def sync
|
204
|
-
@mutex.synchronize{yield}
|
209
|
+
@mutex.synchronize { yield }
|
205
210
|
end
|
206
211
|
end
|
207
212
|
|
@@ -251,7 +256,7 @@ class MessageBus::Postgres::ReliablePubSub
|
|
251
256
|
client.reset!
|
252
257
|
end
|
253
258
|
|
254
|
-
def publish(channel, data, queue_in_memory=true)
|
259
|
+
def publish(channel, data, queue_in_memory = true)
|
255
260
|
client = self.client
|
256
261
|
backlog_id = client.add(channel, data)
|
257
262
|
msg = MessageBus::Message.new backlog_id, backlog_id, channel, data
|
@@ -322,7 +327,7 @@ class MessageBus::Postgres::ReliablePubSub
|
|
322
327
|
@subscribed = false
|
323
328
|
end
|
324
329
|
|
325
|
-
def global_subscribe(last_id=nil, &blk)
|
330
|
+
def global_subscribe(last_id = nil, &blk)
|
326
331
|
raise ArgumentError unless block_given?
|
327
332
|
highest_id = last_id
|
328
333
|
|
@@ -345,7 +350,7 @@ class MessageBus::Postgres::ReliablePubSub
|
|
345
350
|
@subscribed = false
|
346
351
|
end
|
347
352
|
|
348
|
-
on.message do |c,m|
|
353
|
+
on.message do |c, m|
|
349
354
|
if m == UNSUB_MESSAGE
|
350
355
|
@subscribed = false
|
351
356
|
return
|
@@ -1,3 +1,4 @@
|
|
1
|
+
# frozen_string_literal: true
|
1
2
|
require 'redis'
|
2
3
|
# the heart of the message bus, it acts as 2 things
|
3
4
|
#
|
@@ -80,7 +81,7 @@ class MessageBus::Redis::ReliablePubSub
|
|
80
81
|
end
|
81
82
|
end
|
82
83
|
|
83
|
-
def publish(channel, data, queue_in_memory=true)
|
84
|
+
def publish(channel, data, queue_in_memory = true)
|
84
85
|
redis = pub_redis
|
85
86
|
backlog_id_key = backlog_id_key(channel)
|
86
87
|
backlog_key = backlog_key(channel)
|
@@ -122,21 +123,19 @@ class MessageBus::Redis::ReliablePubSub
|
|
122
123
|
backlog_id
|
123
124
|
|
124
125
|
rescue Redis::CommandError => e
|
125
|
-
if queue_in_memory &&
|
126
|
-
e.message =~ /^READONLY/
|
127
|
-
|
126
|
+
if queue_in_memory && e.message =~ /^READONLY/
|
128
127
|
@lock.synchronize do
|
129
|
-
@in_memory_backlog << [channel,data]
|
128
|
+
@in_memory_backlog << [channel, data]
|
130
129
|
if @in_memory_backlog.length > @max_in_memory_publish_backlog
|
131
130
|
@in_memory_backlog.delete_at(0)
|
132
|
-
MessageBus.logger.warn("Dropping old message cause max_in_memory_publish_backlog is full")
|
131
|
+
MessageBus.logger.warn("Dropping old message cause max_in_memory_publish_backlog is full: #{e.message}\n#{e.backtrace.join('\n')}")
|
133
132
|
end
|
134
133
|
end
|
135
134
|
|
136
135
|
if @flush_backlog_thread == nil
|
137
136
|
@lock.synchronize do
|
138
137
|
if @flush_backlog_thread == nil
|
139
|
-
@flush_backlog_thread = Thread.new{ensure_backlog_flushed}
|
138
|
+
@flush_backlog_thread = Thread.new { ensure_backlog_flushed }
|
140
139
|
end
|
141
140
|
end
|
142
141
|
end
|
@@ -164,15 +163,15 @@ class MessageBus::Redis::ReliablePubSub
|
|
164
163
|
end
|
165
164
|
|
166
165
|
begin
|
167
|
-
publish(*@in_memory_backlog[0],false)
|
166
|
+
publish(*@in_memory_backlog[0], false)
|
168
167
|
rescue Redis::CommandError => e
|
169
168
|
if e.message =~ /^READONLY/
|
170
169
|
try_again = true
|
171
170
|
else
|
172
|
-
MessageBus.logger.warn("Dropping undeliverable message #{e}")
|
171
|
+
MessageBus.logger.warn("Dropping undeliverable message: #{e.message}\n#{e.backtrace.join('\n')}")
|
173
172
|
end
|
174
173
|
rescue => e
|
175
|
-
MessageBus.logger.warn("Dropping undeliverable message #{e}")
|
174
|
+
MessageBus.logger.warn("Dropping undeliverable message: #{e.message}\n#{e.backtrace.join('\n')}")
|
176
175
|
end
|
177
176
|
|
178
177
|
@in_memory_backlog.delete_at(0) unless try_again
|
@@ -208,7 +207,7 @@ class MessageBus::Redis::ReliablePubSub
|
|
208
207
|
items.map! do |i|
|
209
208
|
pipe = i.index "|"
|
210
209
|
message_id = i[0..pipe].to_i
|
211
|
-
channel = i[pipe+1..-1]
|
210
|
+
channel = i[pipe + 1..-1]
|
212
211
|
m = get_message(channel, message_id)
|
213
212
|
m
|
214
213
|
end
|
@@ -277,7 +276,7 @@ class MessageBus::Redis::ReliablePubSub
|
|
277
276
|
end
|
278
277
|
end
|
279
278
|
|
280
|
-
def global_subscribe(last_id=nil, &blk)
|
279
|
+
def global_subscribe(last_id = nil, &blk)
|
281
280
|
raise ArgumentError unless block_given?
|
282
281
|
highest_id = last_id
|
283
282
|
|
@@ -293,7 +292,6 @@ class MessageBus::Redis::ReliablePubSub
|
|
293
292
|
end
|
294
293
|
end
|
295
294
|
|
296
|
-
|
297
295
|
begin
|
298
296
|
@redis_global = new_redis_connection
|
299
297
|
|
@@ -313,7 +311,7 @@ class MessageBus::Redis::ReliablePubSub
|
|
313
311
|
@subscribed = false
|
314
312
|
end
|
315
313
|
|
316
|
-
on.message do |c,m|
|
314
|
+
on.message do |c, m|
|
317
315
|
if m == UNSUB_MESSAGE
|
318
316
|
@redis_global.unsubscribe
|
319
317
|
return
|
@@ -344,7 +342,7 @@ class MessageBus::Redis::ReliablePubSub
|
|
344
342
|
private
|
345
343
|
|
346
344
|
def is_readonly?
|
347
|
-
key = "__mb_is_readonly"
|
345
|
+
key = "__mb_is_readonly"
|
348
346
|
|
349
347
|
begin
|
350
348
|
# in case we are not connected to the correct server
|
data/lib/message_bus/client.rb
CHANGED
@@ -1,3 +1,4 @@
|
|
1
|
+
# frozen_string_literal: true
|
1
2
|
class MessageBus::Client
|
2
3
|
attr_accessor :client_id, :user_id, :group_ids, :connect_time,
|
3
4
|
:subscribed_sets, :site_id, :cleanup_timer,
|
@@ -45,21 +46,21 @@ class MessageBus::Client
|
|
45
46
|
|
46
47
|
def ensure_first_chunk_sent
|
47
48
|
if use_chunked && @chunks_sent == 0
|
48
|
-
write_chunk("[]"
|
49
|
+
write_chunk("[]")
|
49
50
|
end
|
50
51
|
end
|
51
52
|
|
52
53
|
def ensure_closed!
|
53
54
|
return unless in_async?
|
54
55
|
if use_chunked
|
55
|
-
write_chunk("[]"
|
56
|
+
write_chunk("[]")
|
56
57
|
if @io
|
57
|
-
@io.write("0\r\n\r\n"
|
58
|
+
@io.write("0\r\n\r\n")
|
58
59
|
@io.close
|
59
60
|
@io = nil
|
60
61
|
end
|
61
62
|
if @async_response
|
62
|
-
@async_response << ("0\r\n\r\n"
|
63
|
+
@async_response << ("0\r\n\r\n")
|
63
64
|
@async_response.done
|
64
65
|
@async_response = nil
|
65
66
|
end
|
@@ -117,21 +118,31 @@ class MessageBus::Client
|
|
117
118
|
|
118
119
|
def backlog
|
119
120
|
r = []
|
120
|
-
|
121
|
+
new_message_ids = nil
|
122
|
+
|
123
|
+
@subscriptions.each do |k, v|
|
121
124
|
next if v.to_i < 0
|
122
125
|
messages = @bus.backlog(k, v, site_id)
|
126
|
+
|
123
127
|
messages.each do |msg|
|
124
|
-
|
128
|
+
if allowed?(msg)
|
129
|
+
r << msg
|
130
|
+
else
|
131
|
+
new_message_ids ||= {}
|
132
|
+
new_message_ids[k] = msg.message_id
|
133
|
+
end
|
125
134
|
end
|
126
135
|
end
|
136
|
+
|
127
137
|
# stats message for all newly subscribed
|
128
138
|
status_message = nil
|
129
|
-
@subscriptions.each do |k,v|
|
130
|
-
if v.to_i == -1
|
139
|
+
@subscriptions.each do |k, v|
|
140
|
+
if v.to_i == -1 || (new_message_ids && new_message_ids[k])
|
131
141
|
status_message ||= {}
|
132
142
|
@subscriptions[k] = status_message[k] = @bus.last_id(k, site_id)
|
133
143
|
end
|
134
144
|
end
|
145
|
+
|
135
146
|
r << MessageBus::Message.new(-1, -1, '/__status', status_message) if status_message
|
136
147
|
|
137
148
|
r || []
|
@@ -139,9 +150,8 @@ class MessageBus::Client
|
|
139
150
|
|
140
151
|
protected
|
141
152
|
|
142
|
-
|
143
153
|
# heavily optimised to avoid all uneeded allocations
|
144
|
-
NEWLINE="\r\n".freeze
|
154
|
+
NEWLINE = "\r\n".freeze
|
145
155
|
COLON_SPACE = ": ".freeze
|
146
156
|
HTTP_11 = "HTTP/1.1 200 OK\r\n".freeze
|
147
157
|
CONTENT_LENGTH = "Content-Length: ".freeze
|
@@ -154,7 +164,7 @@ class MessageBus::Client
|
|
154
164
|
|
155
165
|
def write_headers
|
156
166
|
@io.write(HTTP_11)
|
157
|
-
@headers.each do |k,v|
|
167
|
+
@headers.each do |k, v|
|
158
168
|
next if k == "Content-Type"
|
159
169
|
@io.write(k)
|
160
170
|
@io.write(COLON_SPACE)
|
@@ -1,10 +1,11 @@
|
|
1
|
+
# frozen_string_literal: true
|
1
2
|
require 'json' unless defined? ::JSON
|
2
3
|
|
3
4
|
class MessageBus::ConnectionManager
|
4
5
|
require 'monitor'
|
5
6
|
include MonitorMixin
|
6
7
|
|
7
|
-
def initialize(bus=nil)
|
8
|
+
def initialize(bus = nil)
|
8
9
|
@clients = {}
|
9
10
|
@subscriptions = {}
|
10
11
|
@bus = bus || MessageBus
|
@@ -53,7 +54,7 @@ class MessageBus::ConnectionManager
|
|
53
54
|
|
54
55
|
@clients[client.client_id] = client
|
55
56
|
@subscriptions[client.site_id] ||= {}
|
56
|
-
client.subscriptions.each do |k,v|
|
57
|
+
client.subscriptions.each do |k, v|
|
57
58
|
subscribe_client(client, k)
|
58
59
|
end
|
59
60
|
end
|
@@ -79,7 +80,7 @@ class MessageBus::ConnectionManager
|
|
79
80
|
end
|
80
81
|
end
|
81
82
|
|
82
|
-
def subscribe_client(client,channel)
|
83
|
+
def subscribe_client(client, channel)
|
83
84
|
synchronize do
|
84
85
|
set = @subscriptions[client.site_id][channel]
|
85
86
|
unless set
|
data/lib/message_bus/message.rb
CHANGED
@@ -1,18 +1,20 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
1
3
|
class MessageBus::Message < Struct.new(:global_id, :message_id, :channel , :data)
|
2
4
|
|
3
5
|
attr_accessor :site_id, :user_ids, :group_ids, :client_ids
|
4
6
|
|
5
7
|
def self.decode(encoded)
|
6
8
|
s1 = encoded.index("|")
|
7
|
-
s2 = encoded.index("|", s1+1)
|
8
|
-
s3 = encoded.index("|", s2+1)
|
9
|
+
s2 = encoded.index("|", s1 + 1)
|
10
|
+
s3 = encoded.index("|", s2 + 1)
|
9
11
|
|
10
|
-
MessageBus::Message.new(encoded[0..s1].to_i, encoded[s1+1..s2].to_i,
|
11
|
-
encoded[s2+1..s3-1].gsub("$$123$$", "|"), encoded[s3+1..-1])
|
12
|
+
MessageBus::Message.new(encoded[0..s1].to_i, encoded[(s1 + 1)..s2].to_i,
|
13
|
+
encoded[(s2 + 1)..(s3 - 1)].gsub("$$123$$", "|"), encoded[(s3 + 1)..-1])
|
12
14
|
end
|
13
15
|
|
14
16
|
# only tricky thing to encode is pipes in a channel name ... do a straight replace
|
15
17
|
def encode
|
16
|
-
global_id.to_s << "|" << message_id.to_s << "|" << channel.gsub("|","$$123$$") << "|" << data
|
18
|
+
global_id.to_s << "|" << message_id.to_s << "|" << channel.gsub("|", "$$123$$") << "|" << data
|
17
19
|
end
|
18
20
|
end
|
@@ -1,3 +1,4 @@
|
|
1
|
+
# frozen_string_literal: true
|
1
2
|
module MessageBus::Rack; end
|
2
3
|
|
3
4
|
class MessageBus::Rack::Diagnostics
|
@@ -48,7 +49,7 @@ class MessageBus::Rack::Diagnostics
|
|
48
49
|
</body>
|
49
50
|
</html>
|
50
51
|
HTML
|
51
|
-
return [200, {"content-type" => "text/html;"}, [html]]
|
52
|
+
return [200, { "content-type" => "text/html;" }, [html]]
|
52
53
|
end
|
53
54
|
|
54
55
|
def translate_handlebars(name, content)
|
@@ -80,7 +81,7 @@ HTML
|
|
80
81
|
|
81
82
|
if route =~ /^\/hup\//
|
82
83
|
hostname, pid = route.split('/hup/')[1].split('/')
|
83
|
-
@bus.publish('/_diagnostics/hup',
|
84
|
+
@bus.publish('/_diagnostics/hup', hostname: hostname, pid: pid.to_i)
|
84
85
|
return [200, {}, ['ok']]
|
85
86
|
end
|
86
87
|
|
@@ -89,9 +90,9 @@ HTML
|
|
89
90
|
content = asset_contents(asset)
|
90
91
|
split = asset.split('.')
|
91
92
|
if split[1] == 'handlebars'
|
92
|
-
content = translate_handlebars(split[0],content)
|
93
|
+
content = translate_handlebars(split[0], content)
|
93
94
|
end
|
94
|
-
return [200, {'content-type' => 'text/javascript;'}, [content]]
|
95
|
+
return [200, { 'content-type' => 'text/javascript;' }, [content]]
|
95
96
|
end
|
96
97
|
|
97
98
|
return [404, {}, ['not found']]
|