hey.rb 1.0.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.
@@ -0,0 +1,533 @@
1
+ # Copyright 2014 Google Inc. All Rights Reserved.
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ # Translated from requester/requester.go.
16
+ #
17
+ # Module Hey::Requester provides commands to run load tests and display
18
+ # results. Go's goroutines/channels become Async tasks and queues; net/http
19
+ # becomes async-http.
20
+
21
+ require "base64"
22
+ require "uri"
23
+ require "socket"
24
+
25
+ require "async"
26
+ require "async/barrier"
27
+ require "async/queue"
28
+ require "async/http"
29
+ require "async/http/endpoint"
30
+ require "async/http/proxy"
31
+ require "async/http/middleware/location_redirector"
32
+
33
+ require "io/endpoint/host_endpoint"
34
+
35
+ require "protocol/http"
36
+ require "protocol/http/accept_encoding"
37
+ require "protocol/http/body/buffered"
38
+
39
+ # Go selects now() with a build tag; Ruby selects it at require time.
40
+ if RUBY_PLATFORM.match?(/mswin|mingw|cygwin/)
41
+ require_relative "requester/now_windows"
42
+ else
43
+ require_relative "requester/now_other"
44
+ end
45
+
46
+ require_relative "requester/report"
47
+ require_relative "requester/print"
48
+
49
+ module Hey
50
+ module Requester
51
+ # Max size of the buffer of result queue.
52
+ MAX_RESULT = 1_000_000
53
+ MAX_IDLE_CONN = 500
54
+
55
+ # Go's unexported `result` struct.
56
+ Result = Struct.new(
57
+ :err, # error message, or nil
58
+ :status_code,
59
+ :offset,
60
+ :duration,
61
+ :conn_duration, # connection setup(DNS lookup + Dial up) duration
62
+ :dns_duration, # dns lookup duration
63
+ :req_duration, # request "write" duration
64
+ :res_duration, # response "read" duration
65
+ :delay_duration, # delay between response and request
66
+ :content_length,
67
+ keyword_init: true
68
+ )
69
+
70
+ # Trace stands in for Go's httptrace.ClientTrace. net/http exposes
71
+ # per-phase callbacks; async-http has no equivalent hook, so the phases are
72
+ # recovered by instrumenting the endpoint (connect/DNS) and the request
73
+ # body (write completion), with the trace for the request in flight held in
74
+ # fiber storage.
75
+ class Trace
76
+ attr_accessor :dns_duration, :conn_duration, :conn_done_at, :req_done_at
77
+
78
+ def initialize
79
+ @dns_duration = 0.0
80
+ @conn_duration = 0.0
81
+ @conn_done_at = nil
82
+ @req_done_at = nil
83
+ end
84
+ end
85
+
86
+ # The trace belonging to the request this fiber is performing. Fiber
87
+ # storage is inherited by child fibers, so the connection pool's resource
88
+ # block -- which runs on behalf of the acquiring worker -- sees it too.
89
+ def self.current_trace
90
+ Fiber[:hey_trace]
91
+ end
92
+
93
+ def self.current_trace=(trace)
94
+ Fiber[:hey_trace] = trace
95
+ end
96
+
97
+ # A host endpoint that times name resolution, standing in for Go's
98
+ # DNSStart/DNSDone callbacks. The connect loop mirrors
99
+ # IO::Endpoint::HostEndpoint#connect; only the timing is added.
100
+ class TimingHostEndpoint < ::IO::Endpoint::HostEndpoint
101
+ def connect(wrapper = self.wrapper, &block)
102
+ trace = Requester.current_trace
103
+ return super if trace.nil?
104
+
105
+ last_error = nil
106
+ resolved = false
107
+ dns_start = Requester.now
108
+
109
+ ::Addrinfo.foreach(*@specification) do |address|
110
+ unless resolved
111
+ resolved = true
112
+ trace.dns_duration = Requester.now - dns_start
113
+ end
114
+
115
+ begin
116
+ socket = wrapper.connect(address, **@options)
117
+ rescue => last_error
118
+ # Try again if possible, otherwise raise...
119
+ else
120
+ return socket unless block_given?
121
+
122
+ begin
123
+ return yield(socket)
124
+ ensure
125
+ socket.close
126
+ end
127
+ end
128
+ end
129
+
130
+ raise last_error
131
+ end
132
+ end
133
+
134
+ # An endpoint that times connection establishment, standing in for Go's
135
+ # GetConn/GotConn callbacks. Timing at this level rather than inside the
136
+ # host endpoint means the TLS handshake is included, which is what
137
+ # net/http reports as the connection duration.
138
+ class TimingEndpoint < Async::HTTP::Endpoint
139
+ def connect(&block)
140
+ trace = Requester.current_trace
141
+ return super if trace.nil?
142
+
143
+ conn_start = Requester.now
144
+ begin
145
+ super
146
+ ensure
147
+ done = Requester.now
148
+ trace.conn_duration = done - conn_start
149
+ trace.conn_done_at = done
150
+ end
151
+ end
152
+
153
+ def tcp_endpoint
154
+ TimingHostEndpoint.new(
155
+ [self.hostname, self.port, nil, ::Socket::SOCK_STREAM], **tcp_options
156
+ )
157
+ end
158
+ end
159
+
160
+ # A request body that records when the protocol finished writing it,
161
+ # standing in for Go's WroteRequest callback.
162
+ class TimingBody < ::Protocol::HTTP::Body::Buffered
163
+ def initialize(chunks, trace)
164
+ super(chunks)
165
+ @trace = trace
166
+ end
167
+
168
+ def read
169
+ chunk = super
170
+ if chunk.nil? && @trace && @trace.req_done_at.nil?
171
+ @trace.req_done_at = Requester.now
172
+ end
173
+ chunk
174
+ end
175
+ end
176
+
177
+ # Request is the template a benchmark run is built from. It mirrors the
178
+ # parts of net/http.Request that hey actually sets; each request sent is a
179
+ # fresh Protocol::HTTP::Request cloned from it.
180
+ class Request
181
+ attr_accessor :method, :url, :headers, :host, :content_length, :body
182
+
183
+ # Go: http.NewRequest(method, url, body)
184
+ def initialize(method, url, body = nil)
185
+ @method = method
186
+ @url = url.is_a?(::URI::Generic) ? url : ::URI.parse(url.to_s)
187
+ raise ArgumentError, "URL must be absolute (include scheme, host): #{url}" unless @url.absolute?
188
+
189
+ @headers = ::Protocol::HTTP::Headers.new
190
+ @host = nil
191
+ @content_length = 0
192
+ @body = body
193
+ end
194
+
195
+ # Go: req.SetBasicAuth(username, password)
196
+ def set_basic_auth(username, password)
197
+ credentials = ::Base64.strict_encode64("#{username}:#{password}")
198
+ @headers["authorization"] = "Basic #{credentials}"
199
+ end
200
+
201
+ # The authority to send, honouring an explicit Host override.
202
+ def authority
203
+ return @host if @host && !@host.empty?
204
+
205
+ if @url.port && @url.port != @url.default_port
206
+ "#{@url.hostname}:#{@url.port}"
207
+ else
208
+ @url.hostname
209
+ end
210
+ end
211
+
212
+ def path
213
+ path = @url.path
214
+ path = "/" if path.nil? || path.empty?
215
+ path += "?#{@url.query}" if @url.query
216
+ path
217
+ end
218
+ end
219
+
220
+ # cloneRequest returns a clone of the provided request, as a wire-ready
221
+ # Protocol::HTTP::Request. A fresh one is built per request because a
222
+ # protocol-http body is consumed as it is written.
223
+ def self.clone_request(r, body, trace = nil)
224
+ headers = ::Protocol::HTTP::Headers.new(r.headers.fields.dup)
225
+
226
+ request_body =
227
+ if body && !body.empty?
228
+ TimingBody.new([body.dup], trace)
229
+ end
230
+
231
+ ::Protocol::HTTP::Request.new(
232
+ r.url.scheme, r.authority, r.method, r.path, nil, headers, request_body
233
+ )
234
+ end
235
+
236
+ class Work
237
+ # Request is the request to be made.
238
+ attr_accessor :request
239
+
240
+ attr_accessor :request_body
241
+
242
+ # RequestFunc is a callable to generate requests. If it is nil, then
243
+ # Request and RequestBody are cloned for each request.
244
+ attr_accessor :request_func
245
+
246
+ # N is the total number of requests to make.
247
+ attr_accessor :n
248
+
249
+ # C is the concurrency level, the number of concurrent workers to run.
250
+ attr_accessor :c
251
+
252
+ # H2 is an option to make HTTP/2 requests
253
+ attr_accessor :h2
254
+
255
+ # Timeout in seconds.
256
+ attr_accessor :timeout
257
+
258
+ # QPS is the rate limit in queries per second.
259
+ attr_accessor :qps
260
+
261
+ # DisableCompression is an option to disable compression in response
262
+ attr_accessor :disable_compression
263
+
264
+ # DisableKeepAlives is an option to prevent re-use of TCP connections
265
+ # between different HTTP requests
266
+ attr_accessor :disable_keep_alives
267
+
268
+ # DisableRedirects is an option to prevent the following of HTTP
269
+ # redirects
270
+ attr_accessor :disable_redirects
271
+
272
+ # Output represents the output type. If "csv" is provided, the output
273
+ # will be dumped as a csv stream.
274
+ attr_accessor :output
275
+
276
+ # ProxyAddr is the address of the HTTP proxy server. Optional.
277
+ attr_accessor :proxy_addr
278
+
279
+ # Writer is where results will be written. If nil, results are written
280
+ # to stdout.
281
+ attr_accessor :writer
282
+
283
+ def initialize(request: nil, request_body: nil, request_func: nil,
284
+ n: 0, c: 0, h2: false, timeout: 0, qps: 0.0,
285
+ disable_compression: false, disable_keep_alives: false,
286
+ disable_redirects: false, output: "", proxy_addr: nil,
287
+ writer: nil)
288
+ @request = request
289
+ @request_body = request_body
290
+ @request_func = request_func
291
+ @n = n
292
+ @c = c
293
+ @h2 = h2
294
+ @timeout = timeout
295
+ @qps = qps
296
+ @disable_compression = disable_compression
297
+ @disable_keep_alives = disable_keep_alives
298
+ @disable_redirects = disable_redirects
299
+ @output = output
300
+ @proxy_addr = proxy_addr
301
+ @writer = writer
302
+
303
+ @init_once = false
304
+ @results = nil
305
+ @stop_queue = nil
306
+ @start = 0.0
307
+ @report = nil
308
+ end
309
+
310
+ # Go: b.writer()
311
+ def writer_io
312
+ return $stdout if @writer.nil?
313
+
314
+ @writer
315
+ end
316
+
317
+ # Init initializes internal data-structures.
318
+ def init
319
+ return if @init_once
320
+
321
+ @init_once = true
322
+ @results = Async::LimitedQueue.new([@c * 1000, MAX_RESULT].min)
323
+ @stop_queue = Async::LimitedQueue.new(@c)
324
+ end
325
+
326
+ # Run makes all the requests, prints the summary. It blocks until all
327
+ # work is done. It can be called inside or outside a reactor.
328
+ def run
329
+ Sync { perform }
330
+ end
331
+
332
+ def perform
333
+ init
334
+ @start = Requester.now
335
+ @report = Reporter.new(writer_io, @results, @output, @n)
336
+ # Run the reporter first, it polls the result queue until it is closed.
337
+ reporter = Async { @report.run }
338
+ run_workers
339
+ finish
340
+ reporter.wait
341
+ end
342
+
343
+ def stop
344
+ # Send stop signal so that workers can stop gracefully.
345
+ @c.times { @stop_queue.enqueue(nil) }
346
+ end
347
+
348
+ def finish
349
+ @results.close
350
+ total = Requester.now - @start
351
+ # Wait until the reporter is done.
352
+ @report.done.dequeue
353
+ @report.finalize(total)
354
+ end
355
+
356
+ def make_request(client)
357
+ s = Requester.now
358
+ size = 0
359
+ code = 0
360
+ res_start = 0.0
361
+ err = nil
362
+
363
+ trace = Trace.new
364
+ Requester.current_trace = trace
365
+
366
+ req =
367
+ if @request_func
368
+ @request_func.call
369
+ else
370
+ Requester.clone_request(@request, @request_body, trace)
371
+ end
372
+
373
+ # Go's transport adds this; async-http leaves connection reuse to the
374
+ # pool, so the header is what turns keep-alive off.
375
+ req.headers["connection"] = "close" if @disable_keep_alives
376
+
377
+ call_start = Requester.now
378
+ begin
379
+ with_timeout do
380
+ response = client.call(req)
381
+ begin
382
+ size = response.body&.length || -1
383
+ code = response.status
384
+ res_start = Requester.now
385
+ # Go: io.Copy(io.Discard, resp.Body)
386
+ response.body&.each { |_chunk| }
387
+ ensure
388
+ response.close
389
+ end
390
+ end
391
+ rescue StandardError, Async::TimeoutError => e
392
+ err = error_message(req, e)
393
+ end
394
+
395
+ t = Requester.now
396
+ res_duration = t - res_start
397
+ finish = t - s
398
+
399
+ # Go reads reqStart at GotConn and reqDuration at WroteRequest.
400
+ req_start = trace.conn_done_at || call_start
401
+ req_done = trace.req_done_at || req_start
402
+ req_duration = req_done - req_start
403
+ delay_duration = res_start > 0 ? res_start - req_done : 0.0
404
+
405
+ @results.enqueue(Result.new(
406
+ offset: s,
407
+ status_code: code,
408
+ duration: finish,
409
+ err: err,
410
+ content_length: size,
411
+ conn_duration: trace.conn_duration,
412
+ dns_duration: trace.dns_duration,
413
+ req_duration: req_duration,
414
+ res_duration: res_duration,
415
+ delay_duration: delay_duration
416
+ ))
417
+ ensure
418
+ Requester.current_trace = nil
419
+ end
420
+
421
+ def run_worker(client, n)
422
+ # Go uses a time.Ticker; the deadline is tracked explicitly here, and
423
+ # a missed tick is dropped rather than queued, as the ticker does.
424
+ interval = nil
425
+ next_tick = nil
426
+ if @qps > 0
427
+ interval = (1e6 / @qps).to_i / 1e6
428
+ next_tick = Requester.now + interval
429
+ end
430
+
431
+ n.times do
432
+ # Check if application is stopped. Do not send into a closed queue.
433
+ unless @stop_queue.empty?
434
+ @stop_queue.dequeue
435
+ return
436
+ end
437
+
438
+ if interval
439
+ delay = next_tick - Requester.now
440
+ if delay > 0
441
+ sleep(delay)
442
+ next_tick += interval
443
+ else
444
+ next_tick = Requester.now + interval
445
+ end
446
+ end
447
+
448
+ make_request(client)
449
+ end
450
+ end
451
+
452
+ def run_workers
453
+ endpoint = build_endpoint
454
+ client = Async::HTTP::Client.new(
455
+ endpoint,
456
+ protocol: endpoint.protocol,
457
+ # Go caps idle connections per host at min(C, maxIdleConn); the pool
458
+ # limit here caps live connections, so it tracks the concurrency.
459
+ limit: [@c, 1].max
460
+ )
461
+ app = wrap(client)
462
+
463
+ barrier = Async::Barrier.new
464
+ # Ignore the case where n % c != 0.
465
+ @c.times do
466
+ barrier.async do
467
+ run_worker(app, @n / @c)
468
+ end
469
+ end
470
+ barrier.wait
471
+ ensure
472
+ barrier&.stop
473
+ client&.close
474
+ @proxy_client&.close
475
+ end
476
+
477
+ private
478
+
479
+ # Go configures http.Transport; the equivalents here are the endpoint
480
+ # (TLS, protocol) and a middleware stack (redirects, compression).
481
+ def build_endpoint
482
+ protocol = @h2 ? Async::HTTP::Protocol::HTTP2 : Async::HTTP::Protocol::HTTP11
483
+
484
+ endpoint = TimingEndpoint.new(
485
+ @request.url,
486
+ protocol: protocol,
487
+ # Go: tls.Config{InsecureSkipVerify: true}
488
+ ssl_context: insecure_ssl_context(protocol)
489
+ )
490
+
491
+ if @proxy_addr
492
+ @proxy_client = Async::HTTP::Client.new(Async::HTTP::Endpoint[@proxy_addr])
493
+ # Tunnels via CONNECT; the connection is the proxy's, so the
494
+ # DNS/connect timings above do not apply to it.
495
+ endpoint = Async::HTTP::Proxy.endpoint(@proxy_client, endpoint)
496
+ end
497
+
498
+ endpoint
499
+ end
500
+
501
+ def insecure_ssl_context(protocol)
502
+ context = OpenSSL::SSL::SSLContext.new
503
+ context.alpn_protocols = protocol.names if protocol.respond_to?(:names)
504
+ context.verify_mode = OpenSSL::SSL::VERIFY_NONE
505
+ context
506
+ end
507
+
508
+ def wrap(client)
509
+ app = client
510
+ # net/http follows up to 10 redirects unless told not to; async-http
511
+ # follows none unless asked, so the sense of the flag is inverted.
512
+ app = Async::HTTP::Middleware::LocationRedirector.new(app, 10) unless @disable_redirects
513
+ # net/http requests gzip by default; async-http does not.
514
+ app = ::Protocol::HTTP::AcceptEncoding.new(app) unless @disable_compression
515
+ app
516
+ end
517
+
518
+ # Go: http.Client{Timeout: ...}, where 0 means no timeout.
519
+ def with_timeout(&block)
520
+ return block.call unless @timeout.to_i > 0
521
+
522
+ Async::Task.current.with_timeout(@timeout, &block)
523
+ end
524
+
525
+ # Shaped after net/http's error strings, e.g.
526
+ # Get "http://host/": dial tcp ...: connection refused
527
+ def error_message(req, error)
528
+ verb = req.method.to_s.capitalize
529
+ "#{verb} \"#{@request.url}\": #{error.message}"
530
+ end
531
+ end
532
+ end
533
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hey
4
+ VERSION = "1.0.0"
5
+ end
data/lib/hey.rb ADDED
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "hey/version"
4
+ require_relative "hey/requester"
5
+
6
+ # An HTTP load generator: a Ruby port of rakyll/hey, built on async-http.
7
+ module Hey
8
+ end
metadata ADDED
@@ -0,0 +1,162 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: hey.rb
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.0.0
5
+ platform: ruby
6
+ authors:
7
+ - Nathan Kidd
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 1980-01-01 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: async
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '2.46'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '2.46'
26
+ - !ruby/object:Gem::Dependency
27
+ name: async-http
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: '0.105'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '0.105'
40
+ - !ruby/object:Gem::Dependency
41
+ name: base64
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - "~>"
45
+ - !ruby/object:Gem::Version
46
+ version: '0.3'
47
+ type: :runtime
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - "~>"
52
+ - !ruby/object:Gem::Version
53
+ version: '0.3'
54
+ - !ruby/object:Gem::Dependency
55
+ name: erb
56
+ requirement: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - ">="
59
+ - !ruby/object:Gem::Version
60
+ version: '4.0'
61
+ - - "<"
62
+ - !ruby/object:Gem::Version
63
+ version: '7'
64
+ type: :runtime
65
+ prerelease: false
66
+ version_requirements: !ruby/object:Gem::Requirement
67
+ requirements:
68
+ - - ">="
69
+ - !ruby/object:Gem::Version
70
+ version: '4.0'
71
+ - - "<"
72
+ - !ruby/object:Gem::Version
73
+ version: '7'
74
+ - !ruby/object:Gem::Dependency
75
+ name: io-endpoint
76
+ requirement: !ruby/object:Gem::Requirement
77
+ requirements:
78
+ - - "~>"
79
+ - !ruby/object:Gem::Version
80
+ version: '0.19'
81
+ type: :runtime
82
+ prerelease: false
83
+ version_requirements: !ruby/object:Gem::Requirement
84
+ requirements:
85
+ - - "~>"
86
+ - !ruby/object:Gem::Version
87
+ version: '0.19'
88
+ - !ruby/object:Gem::Dependency
89
+ name: protocol-http
90
+ requirement: !ruby/object:Gem::Requirement
91
+ requirements:
92
+ - - "~>"
93
+ - !ruby/object:Gem::Version
94
+ version: '0.72'
95
+ type: :runtime
96
+ prerelease: false
97
+ version_requirements: !ruby/object:Gem::Requirement
98
+ requirements:
99
+ - - "~>"
100
+ - !ruby/object:Gem::Version
101
+ version: '0.72'
102
+ - !ruby/object:Gem::Dependency
103
+ name: gem_kit
104
+ requirement: !ruby/object:Gem::Requirement
105
+ requirements:
106
+ - - "~>"
107
+ - !ruby/object:Gem::Version
108
+ version: '0.2'
109
+ type: :runtime
110
+ prerelease: false
111
+ version_requirements: !ruby/object:Gem::Requirement
112
+ requirements:
113
+ - - "~>"
114
+ - !ruby/object:Gem::Version
115
+ version: '0.2'
116
+ description: 'hey sends load to a web application and reports a latency histogram,
117
+ percentile distribution and per-phase timings. A file-by-file Ruby port of rakyll/hey:
118
+ goroutines and channels become Async tasks and queues, and net/http becomes async-http.
119
+ The gem is named hey.rb because hey was taken.'
120
+ email:
121
+ - nathankidd@hey.com
122
+ executables:
123
+ - hey
124
+ extensions: []
125
+ extra_rdoc_files: []
126
+ files:
127
+ - CHANGELOG.md
128
+ - LICENSE
129
+ - NOTICE
130
+ - README.md
131
+ - exe/hey
132
+ - lib/hey.rb
133
+ - lib/hey/requester.rb
134
+ - lib/hey/requester/now_other.rb
135
+ - lib/hey/requester/now_windows.rb
136
+ - lib/hey/requester/print.rb
137
+ - lib/hey/requester/report.rb
138
+ - lib/hey/version.rb
139
+ homepage: https://github.com/n-at-han-k/hey.rb
140
+ licenses:
141
+ - Apache-2.0
142
+ metadata:
143
+ source_code_uri: https://github.com/n-at-han-k/hey.rb
144
+ changelog_uri: https://github.com/n-at-han-k/hey.rb/blob/main/CHANGELOG.md
145
+ rdoc_options: []
146
+ require_paths:
147
+ - lib
148
+ required_ruby_version: !ruby/object:Gem::Requirement
149
+ requirements:
150
+ - - ">="
151
+ - !ruby/object:Gem::Version
152
+ version: '3.2'
153
+ required_rubygems_version: !ruby/object:Gem::Requirement
154
+ requirements:
155
+ - - ">="
156
+ - !ruby/object:Gem::Version
157
+ version: '0'
158
+ requirements: []
159
+ rubygems_version: 3.7.2
160
+ specification_version: 4
161
+ summary: An HTTP load generator. A Ruby port of rakyll/hey, built on async-http.
162
+ test_files: []