tipi 0.39 → 0.43

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 (44) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/test.yml +4 -0
  3. data/.gitignore +5 -1
  4. data/CHANGELOG.md +30 -0
  5. data/Gemfile +5 -1
  6. data/Gemfile.lock +62 -25
  7. data/Rakefile +7 -3
  8. data/benchmarks/bm_http1_parser.rb +85 -0
  9. data/bin/benchmark +37 -0
  10. data/bin/h1pd +6 -0
  11. data/bin/tipi +3 -21
  12. data/df/server.rb +16 -87
  13. data/df/server_utils.rb +175 -0
  14. data/examples/full_service.rb +13 -0
  15. data/examples/http1_parser.rb +55 -0
  16. data/examples/http_server.rb +15 -3
  17. data/examples/http_server_forked.rb +3 -1
  18. data/examples/http_server_routes.rb +29 -0
  19. data/examples/http_server_static.rb +26 -0
  20. data/examples/https_server.rb +3 -0
  21. data/examples/servername_cb.rb +37 -0
  22. data/examples/websocket_demo.rb +2 -8
  23. data/examples/ws_page.html +2 -2
  24. data/lib/tipi.rb +89 -1
  25. data/lib/tipi/acme.rb +308 -0
  26. data/lib/tipi/cli.rb +30 -0
  27. data/lib/tipi/digital_fabric/agent.rb +7 -5
  28. data/lib/tipi/digital_fabric/agent_proxy.rb +16 -8
  29. data/lib/tipi/digital_fabric/executive.rb +6 -2
  30. data/lib/tipi/digital_fabric/protocol.rb +18 -3
  31. data/lib/tipi/digital_fabric/request_adapter.rb +0 -4
  32. data/lib/tipi/digital_fabric/service.rb +77 -49
  33. data/lib/tipi/http1_adapter.rb +91 -100
  34. data/lib/tipi/http2_adapter.rb +21 -6
  35. data/lib/tipi/http2_stream.rb +54 -44
  36. data/lib/tipi/rack_adapter.rb +2 -53
  37. data/lib/tipi/response_extensions.rb +17 -0
  38. data/lib/tipi/version.rb +1 -1
  39. data/test/helper.rb +60 -12
  40. data/test/test_http_server.rb +0 -27
  41. data/test/test_request.rb +2 -29
  42. data/tipi.gemspec +11 -7
  43. metadata +79 -26
  44. data/e +0 -0
@@ -15,7 +15,7 @@ module DigitalFabric
15
15
  route[:executive] = true
16
16
  @service.mount(route, self)
17
17
  @current_request_count = 0
18
- @updater = spin_loop(interval: 10) { update_service_stats }
18
+ # @updater = spin_loop(:executive_updater, interval: 10) { update_service_stats }
19
19
  update_service_stats
20
20
  end
21
21
 
@@ -33,9 +33,13 @@ module DigitalFabric
33
33
  req.respond(message.to_json, { 'Content-Type' => 'text.json' })
34
34
  when '/stream/stats'
35
35
  stream_stats(req)
36
+ when '/upload'
37
+ req.respond("body: #{req.read.inspect}")
36
38
  else
37
39
  req.respond('Invalid path', { ':status' => Qeweney::Status::NOT_FOUND })
38
40
  end
41
+ rescue => e
42
+ puts "Error: #{e.inspect}"
39
43
  ensure
40
44
  @current_request_count -= 1
41
45
  end
@@ -43,7 +47,7 @@ module DigitalFabric
43
47
  def stream_stats(req)
44
48
  req.send_headers({ 'Content-Type' => 'text/event-stream' })
45
49
 
46
- @service.timer.every(10) do
50
+ every(10) do
47
51
  message = last_service_stats
48
52
  req.send_chunk(format_sse_event(message.to_json))
49
53
  end
@@ -22,6 +22,9 @@ module DigitalFabric
22
22
 
23
23
  TRANSFER_COUNT = 'transfer_count'
24
24
 
25
+ STATS_REQUEST = 'stats_request'
26
+ STATS_RESPONSE = 'stats_response'
27
+
25
28
  SEND_TIMEOUT = 15
26
29
  RECV_TIMEOUT = SEND_TIMEOUT + 5
27
30
 
@@ -69,6 +72,10 @@ module DigitalFabric
69
72
  RX = 2
70
73
  TX = 3
71
74
  end
75
+
76
+ module Stats
77
+ STATS = 2
78
+ end
72
79
  end
73
80
 
74
81
  class << self
@@ -95,8 +102,8 @@ module DigitalFabric
95
102
  DF_UPGRADE_RESPONSE
96
103
  end
97
104
 
98
- def http_request(id, req)
99
- [ HTTP_REQUEST, id, req.headers, req.next_chunk, req.complete? ]
105
+ def http_request(id, headers, buffered_chunk, complete)
106
+ [ HTTP_REQUEST, id, headers, buffered_chunk, complete ]
100
107
  end
101
108
 
102
109
  def http_response(id, body, headers, complete, transfer_count_key = nil)
@@ -136,12 +143,20 @@ module DigitalFabric
136
143
  end
137
144
 
138
145
  def ws_close(id)
139
- [WS_CLOSE, id ]
146
+ [ WS_CLOSE, id ]
140
147
  end
141
148
 
142
149
  def transfer_count(key, rx, tx)
143
150
  [ TRANSFER_COUNT, key, rx, tx ]
144
151
  end
152
+
153
+ def stats_request(id)
154
+ [ STATS_REQUEST, id ]
155
+ end
156
+
157
+ def stats_response(id, stats)
158
+ [ STATS_RESPONSE, id, stats ]
159
+ end
145
160
  end
146
161
  end
147
162
  end
@@ -17,10 +17,6 @@ module DigitalFabric
17
17
  @agent.get_http_request_body(@id, 1)
18
18
  end
19
19
 
20
- def consume_request(request)
21
- @agent.get_http_request_body(@id, nil)
22
- end
23
-
24
20
  def respond(request, body, headers)
25
21
  @agent.send_df_message(
26
22
  Protocol.http_response(@id, body, headers, true)
@@ -13,26 +13,22 @@ module DigitalFabric
13
13
  @token = token
14
14
  @agents = {}
15
15
  @routes = {}
16
- @waiting_lists = {} # hash mapping routes to arrays of requests waiting for an agent to mount
17
16
  @counters = {
18
17
  connections: 0,
19
18
  http_requests: 0,
20
19
  errors: 0
21
20
  }
22
21
  @connection_count = 0
22
+ @current_request_count = 0
23
23
  @http_latency_accumulator = 0
24
24
  @http_latency_counter = 0
25
+ @http_latency_max = 0
25
26
  @last_counters = @counters.merge(stamp: Time.now.to_f - 1)
26
27
  @fiber = Fiber.current
27
- @timer = Polyphony::Timer.new(resolution: 1)
28
-
29
- stats_updater = spin { @timer.every(10) { update_stats } }
30
- @stats = {}
31
-
32
- @current_request_count = 0
28
+ # @timer = Polyphony::Timer.new('service_timer', resolution: 5)
33
29
  end
34
30
 
35
- def update_stats
31
+ def calculate_stats
36
32
  now = Time.now.to_f
37
33
  elapsed = now - @last_counters[:stamp]
38
34
  connections = @counters[:connections] - @last_counters[:connections]
@@ -40,23 +36,65 @@ module DigitalFabric
40
36
  errors = @counters[:errors] - @last_counters[:errors]
41
37
  @last_counters = @counters.merge(stamp: now)
42
38
 
43
- average_latency = @http_latency_counter > 0 ?
44
- @http_latency_accumulator / @http_latency_counter :
45
- 0
39
+ average_latency = @http_latency_counter == 0 ? 0 :
40
+ @http_latency_accumulator / @http_latency_counter
46
41
  @http_latency_accumulator = 0
47
42
  @http_latency_counter = 0
48
-
49
- @stats = {
50
- connection_rate: connections / elapsed,
51
- http_request_rate: http_requests / elapsed,
52
- error_rate: errors / elapsed,
53
- average_latency: average_latency,
54
- agent_count: @agents.size,
55
- connection_count: @connection_count,
56
- concurrent_requests: @current_request_count
43
+ max_latency = @http_latency_max
44
+ @http_latency_max = 0
45
+
46
+ cpu, rss = pid_cpu_and_rss(Process.pid)
47
+
48
+ backend_stats = Thread.backend.stats
49
+ op_rate = backend_stats[:op_count] / elapsed
50
+ switch_rate = backend_stats[:switch_count] / elapsed
51
+ poll_rate = backend_stats[:poll_count] / elapsed
52
+
53
+ object_space_stats = ObjectSpace.count_objects
54
+
55
+ {
56
+ service: {
57
+ agent_count: @agents.size,
58
+ connection_count: @connection_count,
59
+ connection_rate: connections / elapsed,
60
+ error_rate: errors / elapsed,
61
+ http_request_rate: http_requests / elapsed,
62
+ latency_avg: average_latency,
63
+ latency_max: max_latency,
64
+ pending_requests: @current_request_count,
65
+ },
66
+ backend: {
67
+ op_rate: op_rate,
68
+ pending_ops: backend_stats[:pending_ops],
69
+ poll_rate: poll_rate,
70
+ runqueue_size: backend_stats[:runqueue_size],
71
+ runqueue_high_watermark: backend_stats[:runqueue_max_length],
72
+ switch_rate: switch_rate,
73
+
74
+ },
75
+ process: {
76
+ cpu_usage: cpu,
77
+ rss: rss.to_f / 1024,
78
+ objects_total: object_space_stats[:TOTAL],
79
+ objects_free: object_space_stats[:FREE]
80
+ }
57
81
  }
58
82
  end
59
83
 
84
+ def pid_cpu_and_rss(pid)
85
+ s = `ps -p #{pid} -o %cpu,rss`
86
+ cpu, rss = s.lines[1].chomp.strip.split(' ')
87
+ [cpu.to_f, rss.to_i]
88
+ rescue Polyphony::BaseException
89
+ raise
90
+ rescue Exception
91
+ [nil, nil]
92
+ end
93
+
94
+ def get_stats
95
+ calculate_stats
96
+ end
97
+
60
98
  def incr_connection_count
61
99
  @connection_count += 1
62
100
  end
@@ -77,23 +115,25 @@ module DigitalFabric
77
115
  count
78
116
  end
79
117
 
80
- def record_latency_measurement(latency)
118
+ def record_latency_measurement(latency, req)
81
119
  @http_latency_accumulator += latency
82
120
  @http_latency_counter += 1
121
+ @http_latency_max = latency if latency > @http_latency_max
122
+ return if latency < 1.0
123
+
124
+ puts format('slow request (%.1f): %p', latency, req.headers)
83
125
  end
84
126
 
85
- def http_request(req)
127
+ def http_request(req, allow_df_upgrade = false)
86
128
  @current_request_count += 1
87
129
  @counters[:http_requests] += 1
88
130
  @counters[:connections] += 1 if req.headers[':first']
89
131
 
90
- return upgrade_request(req) if req.upgrade_protocol
132
+ return upgrade_request(req, allow_df_upgrade) if req.upgrade_protocol
91
133
 
92
134
  inject_request_headers(req)
93
135
  agent = find_agent(req)
94
136
  unless agent
95
- return req.respond('pong') if req.query[:q] == 'ping'
96
-
97
137
  @counters[:errors] += 1
98
138
  return req.respond(nil, ':status' => Qeweney::Status::SERVICE_UNAVAILABLE)
99
139
  end
@@ -120,10 +160,14 @@ module DigitalFabric
120
160
  req.headers['x-forwarded-proto'] ||= conn.is_a?(OpenSSL::SSL::SSLSocket) ? 'https' : 'http'
121
161
  end
122
162
 
123
- def upgrade_request(req)
163
+ def upgrade_request(req, allow_df_upgrade)
124
164
  case (protocol = req.upgrade_protocol)
125
165
  when 'df'
126
- df_upgrade(req)
166
+ if allow_df_upgrade
167
+ df_upgrade(req)
168
+ else
169
+ req.respond(nil, ':status' => Qeweney::Status::SERVICE_UNAVAILABLE)
170
+ end
127
171
  else
128
172
  agent = find_agent(req)
129
173
  unless agent
@@ -136,12 +180,16 @@ module DigitalFabric
136
180
  end
137
181
 
138
182
  def df_upgrade(req)
183
+ # we don't want to count connected agents
184
+ @current_request_count -= 1
139
185
  if req.headers['df-token'] != @token
140
186
  return req.respond(nil, ':status' => Qeweney::Status::FORBIDDEN)
141
187
  end
142
188
 
143
189
  req.adapter.conn << Protocol.df_upgrade_response
144
190
  AgentProxy.new(self, req)
191
+ ensure
192
+ @current_request_count += 1
145
193
  end
146
194
 
147
195
  def mount(route, agent)
@@ -151,11 +199,6 @@ module DigitalFabric
151
199
  @executive = agent if route[:executive]
152
200
  @agents[agent] = route
153
201
  @routing_changed = true
154
-
155
- if (waiting = @waiting_lists[route])
156
- waiting.each { |f| f.schedule(agent) }
157
- @waiting_lists.delete(route)
158
- end
159
202
  end
160
203
 
161
204
  def unmount(agent)
@@ -165,8 +208,6 @@ module DigitalFabric
165
208
  @executive = nil if route[:executive]
166
209
  @agents.delete(agent)
167
210
  @routing_changed = true
168
-
169
- @waiting_lists[route] ||= []
170
211
  end
171
212
 
172
213
  INVALID_HOST = 'INVALID_HOST'
@@ -182,12 +223,6 @@ module DigitalFabric
182
223
  end
183
224
  return @routes[route] if route
184
225
 
185
- # # search for a known route for an agent that recently unmounted
186
- # route, wait_list = @waiting_lists.find do |route, _|
187
- # (host == route[:host]) || (path =~ route[:path_regexp])
188
- # end
189
- # return wait_for_agent(wait_list) if route
190
-
191
226
  nil
192
227
  end
193
228
 
@@ -202,13 +237,6 @@ module DigitalFabric
202
237
  @route_keys = @routes.keys
203
238
  end
204
239
 
205
- def wait_for_agent(wait_list)
206
- wait_list << Fiber.current
207
- @timer.move_on_after(10) { suspend }
208
- ensure
209
- wait_list.delete(self)
210
- end
211
-
212
240
  def path_regexp(path)
213
241
  /^#{path}/
214
242
  end
@@ -216,8 +244,8 @@ module DigitalFabric
216
244
  def graceful_shutdown
217
245
  @shutdown = true
218
246
  @agents.keys.each do |agent|
219
- if agent.respond_to?(:shutdown)
220
- agent.shutdown
247
+ if agent.respond_to?(:send_shutdown)
248
+ agent.send_shutdown
221
249
  else
222
250
  @agents.delete(agent)
223
251
  end
@@ -1,9 +1,10 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'http/parser'
4
- require_relative './http2_adapter'
3
+ require 'h1p'
5
4
  require 'qeweney/request'
6
5
 
6
+ require_relative './http2_adapter'
7
+
7
8
  module Tipi
8
9
  # HTTP1 protocol implementation
9
10
  class HTTP1Adapter
@@ -14,123 +15,78 @@ module Tipi
14
15
  @conn = conn
15
16
  @opts = opts
16
17
  @first = true
17
- @parser = ::HTTP::Parser.new(self)
18
+ @parser = H1P::Parser.new(@conn)
18
19
  end
19
20
 
20
21
  def each(&block)
21
- @conn.recv_loop do |data|
22
- return if handle_incoming_data(data, &block)
22
+ while true
23
+ headers = @parser.parse_headers
24
+ break unless headers
25
+
26
+ # handle_request returns true if connection is not persistent or was
27
+ # upgraded
28
+ break if handle_request(headers, &block)
23
29
  end
30
+ rescue H1P::Error
31
+ # ignore
24
32
  rescue SystemCallError, IOError
25
33
  # ignore
26
34
  ensure
27
35
  finalize_client_loop
28
36
  end
29
37
 
30
- # return [Boolean] true if client loop should stop
31
- def handle_incoming_data(data, &block)
32
- rx = data.bytesize
33
- @parser << data
34
- while (request = @requests_head)
35
- request.headers[':rx'] = rx
36
- if @first
37
- request.headers[':first'] = true
38
- @first = nil
39
- end
40
- return true if upgrade_connection(request.headers, &block)
41
-
42
- @requests_head = request.__next__
43
- block.call(request)
44
- return true unless request.keep_alive?
38
+ def handle_request(headers, &block)
39
+ scheme = (proto = headers['x-forwarded-proto']) ?
40
+ proto.downcase : scheme_from_connection
41
+ headers[':scheme'] = scheme
42
+ @protocol = headers[':protocol']
43
+ if @first
44
+ headers[':first'] = true
45
+ @first = nil
46
+ end
47
+
48
+ return true if upgrade_connection(headers, &block)
49
+
50
+ request = Qeweney::Request.new(headers, self)
51
+ if !@parser.complete?
52
+ request.buffer_body_chunk(@parser.read_body_chunk(true))
53
+ end
54
+ block.call(request)
55
+ return !persistent_connection?(headers)
56
+ end
57
+
58
+ def persistent_connection?(headers)
59
+ if headers[':protocol'] == 'http/1.1'
60
+ return headers['connection'] != 'close'
61
+ else
62
+ connection = headers['connection']
63
+ return connection && connection != 'close'
45
64
  end
46
- nil
47
65
  end
48
66
 
49
67
  def finalize_client_loop
50
- # release references to various objects
51
- @requests_head = @requests_tail = nil
52
68
  @parser = nil
69
+ @splicing_pipe = nil
70
+ @conn.shutdown if @conn.respond_to?(:shutdown) rescue nil
53
71
  @conn.close
54
72
  end
55
73
 
56
74
  # Reads a body chunk for the current request. Transfers control to the parse
57
75
  # loop, and resumes once the parse_loop has fired the on_body callback
58
- def get_body_chunk(request)
59
- @waiting_for_body_chunk = true
60
- @next_chunk = nil
61
- while !@requests_tail.complete? && (data = @conn.readpartial(8192))
62
- request.rx_incr(data.bytesize)
63
- @parser << data
64
- return @next_chunk if @next_chunk
65
-
66
- snooze
67
- end
68
- nil
69
- ensure
70
- @waiting_for_body_chunk = nil
71
- end
72
-
73
- # Waits for the current request to complete. Transfers control to the parse
74
- # loop, and resumes once the parse_loop has fired the on_message_complete
75
- # callback
76
- def consume_request(request)
77
- request = @requests_head
78
- @conn.recv_loop do |data|
79
- request.rx_incr(data.bytesize)
80
- @parser << data
81
- return if request.complete?
82
- end
83
- end
84
-
85
- def protocol
86
- version = @parser.http_version
87
- "HTTP #{version.join('.')}"
88
- end
89
-
90
- def on_headers_complete(headers)
91
- headers = normalize_headers(headers)
92
- headers[':path'] = @parser.request_url
93
- headers[':method'] = @parser.http_method.downcase
94
- scheme = (proto = headers['x-forwarded-proto']) ?
95
- proto.downcase : scheme_from_connection
96
- headers[':scheme'] = scheme
97
- queue_request(Qeweney::Request.new(headers, self))
76
+ def get_body_chunk(request, buffered_only = false)
77
+ @parser.read_body_chunk(buffered_only)
98
78
  end
99
79
 
100
- def normalize_headers(headers)
101
- headers.each_with_object({}) do |(k, v), h|
102
- k = k.downcase
103
- hk = h[k]
104
- if hk
105
- hk = h[k] = [hk] unless hk.is_a?(Array)
106
- v.is_a?(Array) ? hk.concat(v) : hk << v
107
- else
108
- h[k] = v
109
- end
110
- end
111
- end
112
-
113
- def queue_request(request)
114
- if @requests_head
115
- @requests_tail.__next__ = request
116
- @requests_tail = request
117
- else
118
- @requests_head = @requests_tail = request
119
- end
80
+ def get_body(request)
81
+ @parser.read_body
120
82
  end
121
-
122
- def on_body(chunk)
123
- if @waiting_for_body_chunk
124
- @next_chunk = chunk
125
- @waiting_for_body_chunk = nil
126
- else
127
- @requests_tail.buffer_body_chunk(chunk)
128
- end
83
+
84
+ def complete?(request)
85
+ @parser.complete?
129
86
  end
130
87
 
131
- def on_message_complete
132
- @waiting_for_body_chunk = nil
133
- @requests_tail.complete!(@parser.keep_alive?)
88
+ def protocol
89
+ @protocol
134
90
  end
135
91
 
136
92
  # Upgrades the connection to a different protocol, if the 'Upgrade' header is
@@ -168,14 +124,15 @@ module Tipi
168
124
  end
169
125
 
170
126
  def upgrade_with_handler(handler, headers)
171
- @parser = @requests_head = @requests_tail = nil
127
+ @parser = nil
172
128
  handler.(self, headers)
173
129
  true
174
130
  end
175
131
 
176
132
  def upgrade_to_http2(headers, &block)
177
- @parser = @requests_head = @requests_tail = nil
178
- HTTP2Adapter.upgrade_each(@conn, @opts, http2_upgraded_headers(headers), &block)
133
+ headers = http2_upgraded_headers(headers)
134
+ body = @parser.read_body
135
+ HTTP2Adapter.upgrade_each(@conn, @opts, headers, body, &block)
179
136
  true
180
137
  end
181
138
 
@@ -208,7 +165,6 @@ module Tipi
208
165
  # @param body [String] response body
209
166
  # @param headers
210
167
  def respond(request, body, headers)
211
- consume_request(request) if @parsing
212
168
  formatted_headers = format_headers(headers, body, false)
213
169
  request.tx_incr(formatted_headers.bytesize + (body ? body.bytesize : 0))
214
170
  if body
@@ -217,7 +173,23 @@ module Tipi
217
173
  @conn.write(formatted_headers)
218
174
  end
219
175
  end
176
+
177
+ def respond_from_io(request, io, headers, chunk_size = 2**14)
178
+ formatted_headers = format_headers(headers, true, true)
179
+ request.tx_incr(formatted_headers.bytesize)
220
180
 
181
+ # assume chunked encoding
182
+ Thread.current.backend.splice_chunks(
183
+ io,
184
+ @conn,
185
+ formatted_headers,
186
+ "0\r\n\r\n",
187
+ ->(len) { "#{len.to_s(16)}\r\n" },
188
+ "\r\n",
189
+ chunk_size
190
+ )
191
+ end
192
+
221
193
  # Sends response headers. If empty_response is truthy, the response status
222
194
  # code will default to 204, otherwise to 200.
223
195
  # @param request [Qeweney::Request] HTTP request
@@ -226,10 +198,14 @@ module Tipi
226
198
  # @param chunked [boolean] whether to use chunked transfer encoding
227
199
  # @return [void]
228
200
  def send_headers(request, headers, empty_response: false, chunked: true)
229
- formatted_headers = format_headers(headers, !empty_response, @parser.http_minor == 1 && chunked)
201
+ formatted_headers = format_headers(headers, !empty_response, http1_1?(request) && chunked)
230
202
  request.tx_incr(formatted_headers.bytesize)
231
203
  @conn.write(formatted_headers)
232
204
  end
205
+
206
+ def http1_1?(request)
207
+ request.headers[':protocol'] == 'http/1.1'
208
+ end
233
209
 
234
210
  # Sends a response body chunk. If no headers were sent, default headers are
235
211
  # sent using #send_headers. if the done option is true(thy), an empty chunk
@@ -248,6 +224,20 @@ module Tipi
248
224
  @conn.write(data)
249
225
  end
250
226
 
227
+ def send_chunk_from_io(request, io, r, w, chunk_size)
228
+ len = w.splice(io, chunk_size)
229
+ if len > 0
230
+ Thread.current.backend.chain(
231
+ [:write, @conn, "#{len.to_s(16)}\r\n"],
232
+ [:splice, r, @conn, len],
233
+ [:write, @conn, "\r\n"]
234
+ )
235
+ else
236
+ @conn.write("0\r\n\r\n")
237
+ end
238
+ len
239
+ end
240
+
251
241
  # Finishes the response to the current request. If no headers were sent,
252
242
  # default headers are sent using #send_headers.
253
243
  # @return [void]
@@ -257,6 +247,7 @@ module Tipi
257
247
  end
258
248
 
259
249
  def close
250
+ @conn.shutdown if @conn.respond_to?(:shutdown) rescue nil
260
251
  @conn.close
261
252
  end
262
253
 
@@ -303,7 +294,7 @@ module Tipi
303
294
  if chunked
304
295
  +"HTTP/1.1 #{status}\r\nTransfer-Encoding: chunked\r\n"
305
296
  else
306
- +"HTTP/1.1 #{status}\r\nContent-Length: #{body.bytesize}\r\n"
297
+ +"HTTP/1.1 #{status}\r\nContent-Length: #{body.is_a?(String) ? body.bytesize : body.to_i}\r\n"
307
298
  end
308
299
  end
309
300