Class: PatientHttp::ResponseReader
- Inherits:
-
Object
- Object
- PatientHttp::ResponseReader
- Defined in:
- lib/patient_http/response_reader.rb
Overview
Reads and decodes HTTP response bodies.
Reading happens on the reactor thread and collects the raw (possibly compressed) body chunks with size validation. Decoding — joining the chunks, inflating compressed content, and applying the charset — is a separate step so it can run on a completion worker thread instead of blocking the event loop.
Defined Under Namespace
Classes: ReadAbortedError
Constant Summary collapse
- INFLATE_WINDOW_BITS =
Content encodings that are inflated during decoding, mapped to the window bits of each wire format the encoding may arrive in. Formats are tried in order until one inflates the body.
A "deflate" body should carry a zlib header (RFC 9110 specifies the zlib format), but some servers send a bare deflate stream instead, so the raw format is kept as a fallback.
{ "gzip" => [Zlib::MAX_WBITS | 16].freeze, "deflate" => [Zlib::MAX_WBITS, -Zlib::MAX_WBITS].freeze }.freeze
- IDENTITY_ENCODING =
Content encoding that means the body was not encoded at all. It needs no work to decode, but it still has to be recognized so it does not stop the decode of the encodings applied before it.
"identity"
Class Method Summary collapse
-
.content_encodings(headers_hash) ⇒ Array<String>
Parse the content-encoding header into encoding names.
-
.decodable?(name) ⇒ Boolean
True if the reader can remove this encoding.
-
.rewrite_content_encoding(headers_hash) ⇒ Hash
Restate the content-encoding header for a decoded body.
-
.split_encodings(headers_hash) ⇒ Array(Array<String>, Array<String>)
Split the encodings named in the content-encoding header into the ones that stay applied to the body and the ones that can be decoded.
Instance Method Summary collapse
-
#decode_body(chunks, headers_hash) ⇒ String?
Decode raw body chunks into the final body string.
-
#initialize(processor, config: nil) ⇒ ResponseReader
constructor
Initialize the reader.
-
#read_raw_body(async_response, headers_hash) ⇒ Array<String>?
Read the raw response body chunks with size validation.
Constructor Details
#initialize(processor, config: nil) ⇒ ResponseReader
Initialize the reader.
Reading needs a processor so it can abort once the processor is past its shutdown deadline. Decoding needs only the configuration, so a caller that does its own reading can supply the configuration on its own.
102 103 104 105 |
# File 'lib/patient_http/response_reader.rb', line 102 def initialize(processor, config: nil) @processor = processor @config = config || processor.config end |
Class Method Details
.content_encodings(headers_hash) ⇒ Array<String>
Parse the content-encoding header into encoding names.
60 61 62 63 64 65 |
# File 'lib/patient_http/response_reader.rb', line 60 def content_encodings(headers_hash) headers_hash["content-encoding"].to_s.split(",").filter_map do |name| name = name.strip.downcase name unless name.empty? end end |
.decodable?(name) ⇒ Boolean
Returns true if the reader can remove this encoding.
69 70 71 |
# File 'lib/patient_http/response_reader.rb', line 69 def decodable?(name) name == IDENTITY_ENCODING || INFLATE_WINDOW_BITS.key?(name) end |
.rewrite_content_encoding(headers_hash) ⇒ Hash
Restate the content-encoding header for a decoded body. The header is removed when nothing is left applied, and narrowed to the encodings the reader could not remove otherwise, so the header always describes the body delivered with it.
80 81 82 83 84 85 86 87 88 89 90 |
# File 'lib/patient_http/response_reader.rb', line 80 def rewrite_content_encoding(headers_hash) return headers_hash unless headers_hash.key?("content-encoding") remaining, _decodable = split_encodings(headers_hash) if remaining.empty? headers_hash.except("content-encoding") else headers_hash.merge("content-encoding" => remaining.join(", ")) end end |
.split_encodings(headers_hash) ⇒ Array(Array<String>, Array<String>)
Split the encodings named in the content-encoding header into the ones that stay applied to the body and the ones that can be decoded.
A body can carry more than one encoding. They are listed in the order they were applied, so decoding runs from the last name backwards and stops at the first name it does not recognize. Everything before that point stays applied to the body.
48 49 50 51 52 53 54 |
# File 'lib/patient_http/response_reader.rb', line 48 def split_encodings(headers_hash) encodings = content_encodings(headers_hash) boundary = encodings.rindex { |name| !decodable?(name) } return [[], encodings] if boundary.nil? [encodings[0..boundary], encodings[(boundary + 1)..]] end |
Instance Method Details
#decode_body(chunks, headers_hash) ⇒ String?
Decode raw body chunks into the final body string.
Joins the chunks, inflates gzip/deflate content (enforcing max_response_size on the inflated bytes), and applies the charset from the Content-Type header. This is CPU-bound work intended to run on a completion worker thread.
An encoding the reader does not support leaves the body encoded, and a body that is still encoded keeps its binary encoding because the charset does not describe it. Use split_encodings to find what stays applied so the content-encoding header delivered with the response describes the body it carries.
145 146 147 148 149 150 151 152 153 154 155 156 157 158 |
# File 'lib/patient_http/response_reader.rb', line 145 def decode_body(chunks, headers_hash) return nil if chunks.nil? remaining, decodable = self.class.split_encodings(headers_hash) warn_undecodable(remaining) unless remaining.empty? body = inflate_encodings(chunks, decodable).join body.force_encoding(Encoding::ASCII_8BIT) # A body that is still encoded is not text yet, so the charset does not # describe its bytes. Leave it binary for the caller to decode. return body unless remaining.empty? apply_charset_encoding(body, headers_hash) end |
#read_raw_body(async_response, headers_hash) ⇒ Array<String>?
Read the raw response body chunks with size validation.
Reads the async HTTP response body asynchronously to completion, which allows the connection to be reused. The async-http client handles connection pooling and keep-alive internally. Using iteration instead of read() ensures non-blocking I/O that yields to the reactor. The chunks are the wire bytes: when the response is compressed, the size check here applies to the compressed bytes and #decode_body applies the same limit to the inflated bytes.
121 122 123 124 125 126 |
# File 'lib/patient_http/response_reader.rb', line 121 def read_raw_body(async_response, headers_hash) return nil unless async_response.body validate_content_length(headers_hash) read_body_chunks(async_response) end |