Skip to content

Commit 9ece024

Browse files
authored
Refactoring resp_stream_*() (#704) (#856)
This originally started as refactoring designed to fix the quadratic memory growth observed in #704, but it's grown into a ground up rewrite to make it all easier to understand and considerably more efficient. Now all streaming functions that break data up into chunks (i.e. `resp_stream_lines()`, `resp_stream_sse()`, and `resp_stream_aws()`) use the same infrastructure, which has been extensively refactored to make it as easy as possible to understand. Includes a number of small fixes to improve fidelity of AWS stream parsing. Fixes #704
1 parent 0d1b580 commit 9ece024

21 files changed

Lines changed: 1277 additions & 816 deletions

DESCRIPTION

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ Suggests:
3232
bench,
3333
clipr,
3434
covr,
35+
digest,
3536
docopt,
3637
httpuv,
3738
jose,

NEWS.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,10 @@ e.g., `application/problem+json` (@cgiachalis, #782).
1515
* `req_body_form()` and `req_url_query()` no longer error with "C stack usage is too close to the limit" when given very long string values (#805).
1616
* `req_body_form()` now creates a valid empty request body when no parameters
1717
are provided (@arcresu, #836).
18+
* `resp_stream_aws()` now parses `byte`, `short`, and `integer` headers as signed integers, matching the AWS event-stream specification (previously they were incorrectly read as unsigned).
19+
* `resp_stream_lines()` no longer treats a bare carriage return (CR) as a line ending; only LF and CRLF terminate lines, which is what every modern streaming source produces.
20+
* `resp_stream_lines()` no longer warns when the stream ends without a final line terminator (which is routine when streaming), and its `warn` argument is (softly) deprecated.
21+
* `resp_stream_lines()`, `resp_stream_sse()`, and `resp_stream_aws()` now decode whole chunks at a time and hold the results in a queue, instead of rescanning and recopying the buffer for every line or event. This makes memory use and run time scale linearly rather than quadratically with the response size, so large streams use dramatically less memory and run much faster (e.g. reading a 1 MB response of short lines is now around 200x faster and allocates around 180x less memory) (#704).
1822
* `req_throttle()` can now enforce multiple rate limits at once: supply a vector to `capacity` (and `fill_time_s`) to create one token bucket per limit, and each request must satisfy all of them (#555).
1923
* `req_auth_aws_v4()` now correctly signs URLs containing encoded slashes (`%2F`) in path segments, such as ARNs in AWS Bedrock API paths (@thisisnic, #842).
2024
* `req_error()` is now applied to responses retrieved from the cache, so a custom `is_error` callback is respected on cache hits (#806).

R/req-perform-connection.R

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -252,3 +252,17 @@ StreamingBody <- R6::R6Class(
252252
conn = NULL
253253
)
254254
)
255+
256+
# isOpen doesn't work for two reasons:
257+
# 1. It errors if con has been closed, rather than returning FALSE
258+
# 2. If returns TRUE if con has been closed and a new connection opened
259+
#
260+
# So instead we retrieve the connection from its number and compare to the
261+
# original connection. This works because connections have an undocumented
262+
# external pointer.
263+
isValid <- function(con) {
264+
tryCatch(
265+
identical(getConnection(con), con),
266+
error = function(cnd) FALSE
267+
)
268+
}

R/resp-stream-aws.R

Lines changed: 58 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,15 @@
11
#' @export
22
#' @rdname resp_stream_raw
3-
#' @order 2
3+
#' @order 4
44
resp_stream_aws <- function(resp, max_size = Inf) {
5-
event_bytes <- resp_boundary_pushback(
6-
resp = resp,
7-
max_size = max_size,
8-
boundary_func = find_aws_event_boundary,
9-
include_trailer = FALSE
10-
)
5+
splitter <- init_streaming_response(resp, AwsSplitter)
6+
check_number_whole(max_size, min = 1, allow_infinite = TRUE)
117

12-
if (is.null(event_bytes)) {
8+
blocks <- stream_pull(resp, 1, splitter, max_size)
9+
if (length(blocks) == 0L) {
1310
return()
1411
}
12+
event_bytes <- blocks[[1L]]
1513

1614
event <- parse_aws_event(event_bytes)
1715
if (resp_stream_show_body(resp)) {
@@ -26,24 +24,50 @@ resp_stream_aws <- function(resp, max_size = Inf) {
2624
event
2725
}
2826

29-
find_aws_event_boundary <- function(buffer) {
30-
# No valid AWS event message is less than 16 bytes
31-
if (length(buffer) < 16) {
32-
return(NULL)
33-
}
34-
35-
# Read first 4 bytes as a big endian number
36-
event_size <- parse_int(buffer[1:4])
37-
if (event_size > length(buffer)) {
38-
return(NULL)
27+
AwsSplitter <- R6::R6Class(
28+
"AwsSplitter",
29+
inherit = StreamSplitter,
30+
public = list(
31+
name = "resp_stream_aws()",
32+
find_boundaries = function(buffer) find_aws_event_boundaries(buffer)
33+
)
34+
)
35+
36+
# Find every complete AWS event in a buffer by walking the 4-byte big-endian
37+
# length prefix at the start of each event. Returns a vector of split points
38+
# (the position one past the end of each complete event).
39+
find_aws_event_boundaries <- function(buffer) {
40+
n <- length(buffer)
41+
splits <- double()
42+
pos <- 1
43+
repeat {
44+
# No valid AWS event message is less than 16 bytes.
45+
if (n - pos + 1L < 16L) {
46+
break
47+
}
48+
# Read the first 4 bytes of the event as a big endian number.
49+
event_size <- parse_int(buffer[pos:(pos + 3L)])
50+
if (event_size > n - pos + 1L) {
51+
break
52+
}
53+
pos <- pos + event_size
54+
splits[[length(splits) + 1L]] <- pos
3955
}
40-
41-
event_size + 1
56+
splits
4257
}
4358

44-
# Implementation from https://github.com/lifion/lifion-aws-event-stream/blob/develop/lib/index.js
45-
# This is technically buggy because it takes the header_length as a lower bound
46-
# but this shouldn't cause problems in practive
59+
# Parse a single AWS event-stream message (content type
60+
# application/vnd.amazon.eventstream). The binary format is documented by AWS:
61+
# * https://smithy.io/2.0/aws/amazon-eventstream.html (canonical protocol spec)
62+
# * https://docs.aws.amazon.com/lexv2/latest/dg/event-stream-encoding.html
63+
# Reference implementation: https://github.com/awslabs/aws-eventstream-java
64+
#
65+
# Key details: all integers are big-endian; the prelude (total + header lengths)
66+
# and the whole message each end in a GZIP/zlib CRC32; header value types
67+
# byte/short/integer/long are signed; timestamp is an int64 of epoch millis.
68+
#
69+
# We treat header_length as a lower bound rather than an exact count; this is
70+
# lenient but harmless and matches some reference implementations.
4771
parse_aws_event <- function(bytes) {
4872
i <- 1
4973
read_bytes <- function(n) {
@@ -80,9 +104,9 @@ parse_aws_event <- function(bytes) {
80104
type_enum(type),
81105
"TRUE" = TRUE,
82106
"FALSE" = FALSE,
83-
BYTE = parse_int(read_bytes(1)),
84-
SHORT = parse_int(read_bytes(2)),
85-
INTEGER = parse_int(read_bytes(4)),
107+
BYTE = parse_int(read_bytes(1), signed = TRUE),
108+
SHORT = parse_int(read_bytes(2), signed = TRUE),
109+
INTEGER = parse_int(read_bytes(4), signed = TRUE),
86110
LONG = parse_int64(read_bytes(8)),
87111
BYTE_ARRAY = read_bytes(length),
88112
CHARACTER = rawToChar(read_bytes(length)),
@@ -108,8 +132,13 @@ parse_aws_event <- function(bytes) {
108132

109133
# Helpers ----------------------------------------------------------------
110134

111-
parse_int <- function(x) {
112-
sum(as.integer(x) * 256^rev(seq_along(x) - 1))
135+
parse_int <- function(x, signed = FALSE) {
136+
v <- sum(as.integer(x) * 256^rev(seq_along(x) - 1))
137+
if (signed && v >= 2^(8 * length(x) - 1)) {
138+
# Interpret as two's complement.
139+
v <- v - 2^(8 * length(x))
140+
}
141+
v
113142
}
114143

115144
parse_int64 <- function(x) {
@@ -119,7 +148,7 @@ parse_int64 <- function(x) {
119148
}
120149

121150
type_enum <- function(value) {
122-
if (value < 0 || value > 10) {
151+
if (value < 0 || value > 9) {
123152
cli::cli_abort("Unsupported type {value}.", .internal = TRUE)
124153
}
125154

@@ -138,13 +167,6 @@ type_enum <- function(value) {
138167
)
139168
}
140169

141-
hex_to_raw <- function(x) {
142-
x <- gsub("(\\s|\n)+", "", x)
143-
144-
pairs <- substring(x, seq(1, nchar(x), by = 2), seq(2, nchar(x), by = 2))
145-
as.raw(strtoi(pairs, 16L))
146-
}
147-
148170
raw_to_hex <- function(x) {
149171
paste(as.character(x), collapse = "")
150172
}

R/resp-stream-lines.R

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
#' @export
2+
#' @rdname resp_stream_raw
3+
#' @param lines The maximum number of lines to return at once.
4+
#' @param warn `r lifecycle::badge("deprecated")` `resp_stream_lines()` no longer
5+
#' warns when the connection ends without a final EOL, so this argument is
6+
#' ignored.
7+
#' @order 2
8+
resp_stream_lines <- function(
9+
resp,
10+
lines = 1,
11+
max_size = Inf,
12+
warn = deprecated()
13+
) {
14+
splitter <- init_streaming_response(resp, LineSplitter)
15+
check_number_whole(lines, min = 0, allow_infinite = TRUE)
16+
check_number_whole(max_size, min = 1, allow_infinite = TRUE)
17+
if (lifecycle::is_present(warn) && !isFALSE(warn)) {
18+
lifecycle::deprecate_warn("1.2.3", "resp_stream_lines(warn)")
19+
}
20+
21+
if (lines == 0) {
22+
return(character())
23+
}
24+
25+
encoding <- env_cache(resp$cache, "stream_encoding", resp_encoding(resp))
26+
blocks <- stream_pull(resp, lines, splitter, max_size)
27+
lines_read <- stream_parse_lines(blocks, encoding)
28+
if (resp_stream_show_body(resp)) {
29+
log_stream(lines_read)
30+
}
31+
lines_read
32+
}
33+
34+
# Splits a stream into lines terminated by LF (and hence CRLF)
35+
LineSplitter <- R6::R6Class(
36+
"LineSplitter",
37+
inherit = StreamSplitter,
38+
public = list(
39+
name = "resp_stream_lines()",
40+
find_boundaries = function(buffer) {
41+
grepRaw(as.raw(0x0A), buffer, fixed = TRUE, all = TRUE) + 1L
42+
},
43+
# At end of stream, a trailing line without a terminator is still a line.
44+
finish = function(remainder) {
45+
if (length(remainder) == 0L) list() else list(remainder)
46+
}
47+
)
48+
)
49+
50+
# Decode raw line blocks (each a line plus its trailing LF or CRLF) into a
51+
# character vector in `encoding`, dropping the terminators.
52+
stream_parse_lines <- function(blocks, encoding) {
53+
text <- vapply(blocks, rawToChar, character(1))
54+
Encoding(text) <- "bytes"
55+
text <- iconv(text, encoding, "UTF-8")
56+
sub("\r?\n$", "", text)
57+
}

0 commit comments

Comments
 (0)