Skip to content

Commit 2cec1c1

Browse files
Keep the error body of a failed streaming request, and fix the three framing defects the review found (#14)
* fix(http): keep the error body of a failed streaming request send() fills HttpResponse::body on every path including failures; send_stream() was the one entry point that dropped it. A non-2xx answer to a streaming request is an error document, not an event stream: SseParser finds no event boundary in it, emits nothing, and the bytes stay in its private buffer. Callers were left with a status line and no reason. Capture the body when the status is not 2xx. Events are still parsed and dispatched exactly as before, and nothing is copied on a 2xx stream, so the success path is byte-identical. The copy is bounded by stream_error_body_limit (1 MiB) so a server answering 5xx with an endless body cannot grow the buffer without limit. Truncation lives in an exported append_within_limit, in the same spirit as parse_chunk_size_line, with three unit tests for under, across and past the limit; a live test against httpbin's /status/418 covers the wiring. * Honour a declared Content-Length in send_stream, and reject a chunk size rather than salvaging it Review of the change this branch already carries. The defect it reports is real and the fix is placed correctly --- `dispatch` is the single funnel for every body byte on both framing paths, and `captureBody` is decided after the headers are read, where the status is final. Measured against master, one program, one source file: master status=418 events=0 body.size()=0 this status=418 events=0 body.size()=135 What follows is what that fix could not do on its own. --- 1. A DECLARED LENGTH, WHICH IS WHY THE TEST HAD TO CLOSE THE CONNECTION ---- `send_stream` had no branch for `Content-Length`: a response that was not chunked was read until the connection closed, whatever its headers said. On this library's own defaults --- `keepAlive = true`, so the request carries `Connection: keep-alive` --- the server does not close, and the read loop ran until `readTimeoutMs` expired. Measured against httpbin's `/status/418`: keepAlive = false status=418 body=135 elapsed 1370 ms keepAlive = true status=418 body=135 elapsed 9379 ms (timeout 8000) The error body arrived either way, and on the defaults it arrived a full read timeout late --- sixty seconds, as the defaults stand. `send()` has had this branch throughout, which is the same asymmetry between the two entry points that this branch exists to remove. The live test set `keepAlive = false`, "so the server closes and the read loop ends". That comment was the defect, and the test was examining the one arrangement in which it does not appear. It now runs on the defaults and asserts the elapsed time. after: keepAlive = true status=418 body=135 elapsed 1192 ms --- 2. A CHUNK SIZE THAT DOES NOT PARSE IS NOT A TERMINAL CHUNK --------------- `parse_hex` returns what it accumulated when it meets a character it does not recognise, and zero for an empty line --- and `read_line` returns an empty line on a timeout or a closed connection. So a stream that was cut short read as a stream that ended cleanly and this loop reported success. #9 established `parse_chunk_size_line` for exactly this and it reached `download_to_file` alone; `send` and `send_stream` were left on the old one. --- 3. Content-Length WAS PARSED BY KEEPING THE DIGITS ------------------------ Measured, by compiling that parser on its own: "135" -> 135 "abc" -> 0 <- a refusal read as a real zero "12abc" -> 12 <- stops twelve bytes in "-1" -> 1 <- the sign is discarded "99999999999999999999" -> 7766279631452241919 <- wraps, in silence The last two are the ones no care at the call site could recover from, because what it receives is a plausible number. `parse_content_length` is exported and shaped like `parse_chunk_size_line`, for the reason #9 gave: it is the half of the body framing that can be examined without a server. Both readers use it. --- criteria ----------------------------------------------------------------- Six unit tests over the two pure parsers, and two live ones: the failed stream now runs on the DEFAULT configuration with the elapsed time asserted, and a chunked 2xx is asserted to leave `body` empty and to return promptly --- the success path is the one this change restructured around, so it is observed rather than assumed. 17 tests from 6 suites pass, plus 3 in test_resolver. --------- Co-authored-by: Cloud_Yun <yunfeng66645@gmail.com>
1 parent 4aabaae commit 2cec1c1

3 files changed

Lines changed: 272 additions & 9 deletions

File tree

mcpp.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
[package]
22
namespace = "mcpplibs"
33
name = "tinyhttps"
4-
version = "0.2.9"
4+
version = "0.2.10"
55
description = "Minimal C++23 HTTP/HTTPS client with SSE streaming support"
66
license = "Apache-2.0"
77
repo = "https://github.com/mcpplibs/tinyhttps"

src/http.cppm

Lines changed: 122 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -242,6 +242,37 @@ static int parse_hex(std::string_view s) {
242242
return result;
243243
}
244244

245+
// `Content-Length`, rejected rather than salvaged.
246+
//
247+
// Both readers parsed this by walking the characters and keeping the digits, so
248+
// `12abc` was 12, `abc` was 0 — indistinguishable from a genuine `0` — and a
249+
// value past the width of the accumulator wrapped in silence. A length is the
250+
// number of bytes the reader will then trust, so a wrong one is not a cosmetic
251+
// error: too small leaves the next response's bytes in the stream, and too
252+
// large waits for bytes that are not coming.
253+
//
254+
// Shaped like `parse_chunk_size_line` above, and exported for the same reason:
255+
// it is the half of the body framing that can be examined without a server.
256+
export std::optional<std::int64_t>
257+
parse_content_length(std::string_view value) {
258+
// A field value may carry optional whitespace on either side (RFC 9110).
259+
while (!value.empty() && (value.front() == ' ' || value.front() == '\t'))
260+
value.remove_prefix(1);
261+
while (!value.empty() && (value.back() == ' ' || value.back() == '\t'))
262+
value.remove_suffix(1);
263+
if (value.empty()) return std::nullopt;
264+
265+
std::uint64_t parsed {};
266+
auto [end, error] = std::from_chars(
267+
value.data(), value.data() + value.size(), parsed, 10);
268+
if (error != std::errc{} || end != value.data() + value.size()
269+
|| parsed > static_cast<std::uint64_t>(
270+
std::numeric_limits<std::int64_t>::max())) {
271+
return std::nullopt;
272+
}
273+
return static_cast<std::int64_t>(parsed);
274+
}
275+
245276
export std::optional<std::int64_t>
246277
parse_chunk_size_line(std::string_view line) {
247278
if (line.empty()) return std::nullopt;
@@ -256,6 +287,25 @@ parse_chunk_size_line(std::string_view line) {
256287
return static_cast<std::int64_t>(value);
257288
}
258289

290+
// A streaming request that fails is answered with an error document, not an
291+
// event stream, so SseParser finds no event boundary in it and yields nothing.
292+
// The bytes are still worth keeping: without them a caller can report the
293+
// status line but never the reason. Bounded, so a server answering 5xx with an
294+
// endless body cannot grow the buffer without limit.
295+
export inline constexpr std::size_t stream_error_body_limit = 1024 * 1024;
296+
297+
// Appends as much of `data` as `limit` still allows. Returns false once the
298+
// buffer is full, so a caller can stop copying without tracking sizes itself.
299+
export bool append_within_limit(std::string& buffer, std::string_view data,
300+
std::size_t limit) {
301+
if (buffer.size() >= limit) return false;
302+
const std::size_t room = limit - buffer.size();
303+
// Not std::min: <winsock2.h> defines a `min` macro on Windows.
304+
const std::size_t take = data.size() < room ? data.size() : room;
305+
buffer.append(data.substr(0, take));
306+
return take == data.size();
307+
}
308+
259309
// Case-insensitive string comparison
260310
static bool iequals(std::string_view a, std::string_view b) {
261311
if (a.size() != b.size()) return false;
@@ -459,12 +509,11 @@ private:
459509
chunked = true;
460510
}
461511
if (iequals(key, "Content-Length")) {
462-
contentLength = 0;
463-
for (char c : valStr) {
464-
if (c >= '0' && c <= '9') {
465-
contentLength = contentLength * 10 + (c - '0');
466-
}
467-
}
512+
// Rejected rather than salvaged; parse_content_length says
513+
// why. A malformed value leaves this at -1, which is the
514+
// same state as an absent header and is a framing this
515+
// reader already handles.
516+
contentLength = parse_content_length(valStr).value_or(-1);
468517
}
469518
if (iequals(key, "Connection") && iequals(valStr, "close")) {
470519
connectionClose = true;
@@ -704,6 +753,7 @@ public:
704753
// Read headers
705754
bool chunked = false;
706755
bool connectionClose = false;
756+
std::int64_t contentLength = -1;
707757

708758
while (true) {
709759
std::string headerLine = read_line(*sock, config_.readTimeoutMs);
@@ -726,14 +776,27 @@ public:
726776
if (iequals(key, "Connection") && iequals(valStr, "close")) {
727777
connectionClose = true;
728778
}
779+
// send() reads this and send_stream() did not, which is the
780+
// same asymmetry this change exists to remove. See the body
781+
// loop below for what its absence cost.
782+
if (iequals(key, "Content-Length")) {
783+
contentLength = parse_content_length(valStr).value_or(-1);
784+
}
729785
}
730786
}
731787

732788
// Stream body incrementally, feeding chunks to SseParser
733789
SseParser parser;
734790
bool stopped = false;
791+
// send() fills `body` on every path including failures; without this
792+
// send_stream would be the one entry point that drops it. Capturing is
793+
// additive — events are still parsed and dispatched exactly as before.
794+
const bool captureBody = !response.ok();
735795

736796
auto dispatch = [&](std::string_view data) -> bool {
797+
if (captureBody) {
798+
append_within_limit(response.body, data, stream_error_body_limit);
799+
}
737800
auto events = parser.feed(data);
738801
for (const auto& ev : events) {
739802
if (!callback(ev)) {
@@ -756,7 +819,22 @@ public:
756819
sizeLine.pop_back();
757820
}
758821

759-
int chunkSize = parse_hex(sizeLine);
822+
// A CHUNK HEADER THAT DOES NOT PARSE IS NOT A TERMINAL CHUNK.
823+
//
824+
// `parse_hex` returned what it had accumulated when it met a
825+
// character it did not recognise, and zero for an empty line —
826+
// and `read_line` returns an empty line on a timeout or a
827+
// closed connection. So a stream that was cut short read as a
828+
// stream that ended cleanly, and this loop reported success.
829+
// #9 established `parse_chunk_size_line` for exactly this and
830+
// it reached only `download_to_file`.
831+
auto parsedChunkSize = parse_chunk_size_line(sizeLine);
832+
if (!parsedChunkSize) {
833+
response.statusText = "Invalid chunk size: " + sizeLine;
834+
connectionClose = true;
835+
break;
836+
}
837+
int chunkSize = static_cast<int>(*parsedChunkSize);
760838
if (chunkSize == 0) {
761839
// Terminal chunk — read trailing \r\n
762840
read_line(*sock, config_.readTimeoutMs);
@@ -775,8 +853,44 @@ public:
775853
break;
776854
}
777855
}
856+
} else if (contentLength >= 0) {
857+
// A DECLARED LENGTH IS READ AND THE READER THEN STOPS.
858+
//
859+
// This branch did not exist: a response that was not chunked was
860+
// read until the connection closed, whatever its headers said. On
861+
// the library's own defaults — `keepAlive = true`, so the request
862+
// carries `Connection: keep-alive` — the server does not close,
863+
// and the loop below ran until `readTimeoutMs` expired.
864+
//
865+
// Measured against httpbin's `/status/418`, which answers with a
866+
// Content-Length and keeps the connection:
867+
//
868+
// keepAlive = false status=418 body=135 elapsed 1370 ms
869+
// keepAlive = true status=418 body=135 elapsed 9379 ms (readTimeoutMs = 8000)
870+
//
871+
// The error body arrived either way, and on the defaults it arrived
872+
// a full read timeout late — sixty seconds, as the defaults stand.
873+
// `send()` has had this branch throughout, which is why the same
874+
// request through it returns at once.
875+
std::int64_t remaining = contentLength;
876+
char buf[4096];
877+
while (!stopped && remaining > 0) {
878+
if (!sock->wait_readable(config_.readTimeoutMs)) {
879+
break;
880+
}
881+
const auto want = static_cast<std::size_t>(
882+
remaining < static_cast<std::int64_t>(sizeof buf)
883+
? remaining : static_cast<std::int64_t>(sizeof buf));
884+
int ret = sock->read(buf, want);
885+
if (ret <= 0) break;
886+
remaining -= ret;
887+
if (!dispatch(std::string_view(buf, static_cast<std::size_t>(ret)))) {
888+
break;
889+
}
890+
}
778891
} else {
779-
// Not chunked — read until connection closes
892+
// Neither chunked nor a declared length: the end of the body is the
893+
// end of the connection, so the connection must not be reused.
780894
connectionClose = true;
781895
char buf[4096];
782896
while (!stopped) {

tests/test_download.cpp

Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,75 @@ TEST(ChunkedProtocol, AcceptsValidSizeAndTerminalChunk) {
2020
EXPECT_EQ(*https::parse_chunk_size_line("0"), 0);
2121
}
2222

23+
// `Content-Length` decides how many bytes a reader will trust, so a wrong
24+
// answer is not cosmetic: too small leaves the next response's bytes in the
25+
// stream and too large waits for bytes that are not coming.
26+
//
27+
// Both readers used to keep the digits and discard everything else. Measured,
28+
// by compiling that parser on its own:
29+
//
30+
// "135" -> 135
31+
// "0" -> 0
32+
// "abc" -> 0 <- a refusal read as a real zero
33+
// "12abc" -> 12 <- stops twelve bytes in
34+
// "" -> 0
35+
// "-1" -> 1 <- the sign is discarded
36+
// "99999999999999999999" -> 7766279631452241919 <- wraps, in silence
37+
//
38+
// The last two are the ones no amount of care at the call site could recover
39+
// from, because the value it receives is a plausible number.
40+
TEST(ContentLength, AcceptsAWellFormedValue) {
41+
ASSERT_TRUE(https::parse_content_length("135").has_value());
42+
EXPECT_EQ(*https::parse_content_length("135"), 135);
43+
// A field value may carry optional whitespace on either side.
44+
ASSERT_TRUE(https::parse_content_length(" 135\t").has_value());
45+
EXPECT_EQ(*https::parse_content_length(" 135\t"), 135);
46+
}
47+
48+
// The one a salvaging parser cannot express. `abc` used to yield 0, which is
49+
// indistinguishable from a server that genuinely declared an empty body — and
50+
// the two call for opposite behaviour.
51+
TEST(ContentLength, AZeroIsDistinguishableFromARefusal) {
52+
ASSERT_TRUE(https::parse_content_length("0").has_value());
53+
EXPECT_EQ(*https::parse_content_length("0"), 0);
54+
EXPECT_FALSE(https::parse_content_length("abc").has_value());
55+
}
56+
57+
TEST(ContentLength, RejectsEmptyTrailingGarbageAndOverflow) {
58+
EXPECT_FALSE(https::parse_content_length("").has_value());
59+
EXPECT_FALSE(https::parse_content_length(" ").has_value());
60+
// `12abc` used to be 12: the reader would then stop twelve bytes in and
61+
// leave the rest of the body to be read as the next response.
62+
EXPECT_FALSE(https::parse_content_length("12abc").has_value());
63+
EXPECT_FALSE(https::parse_content_length("-1").has_value());
64+
EXPECT_FALSE(https::parse_content_length("+1").has_value());
65+
// Past the width of the accumulator, which used to wrap in silence.
66+
EXPECT_FALSE(https::parse_content_length("99999999999999999999").has_value());
67+
}
68+
69+
TEST(StreamErrorBody, KeepsEverythingWhileUnderLimit) {
70+
std::string buffer;
71+
EXPECT_TRUE(https::append_within_limit(buffer, "abc", 8));
72+
EXPECT_TRUE(https::append_within_limit(buffer, "de", 8));
73+
EXPECT_EQ(buffer, "abcde");
74+
}
75+
76+
TEST(StreamErrorBody, TruncatesTheChunkThatCrossesTheLimit) {
77+
std::string buffer = "abc";
78+
EXPECT_FALSE(https::append_within_limit(buffer, "defgh", 5));
79+
EXPECT_EQ(buffer, "abcde");
80+
}
81+
82+
TEST(StreamErrorBody, RefusesFurtherDataOnceFull) {
83+
std::string buffer = "abcde";
84+
EXPECT_FALSE(https::append_within_limit(buffer, "f", 5));
85+
EXPECT_EQ(buffer, "abcde");
86+
// A zero limit must not append anything, not even an empty append.
87+
std::string empty;
88+
EXPECT_FALSE(https::append_within_limit(empty, "a", 0));
89+
EXPECT_TRUE(empty.empty());
90+
}
91+
2392
TEST(DownloadResultContract, CarriesTransferAndResponseMetadata) {
2493
https::DownloadToFileResult result;
2594
result.bytesWritten = 42;
@@ -36,6 +105,86 @@ TEST(DownloadResultContract, CarriesTransferAndResponseMetadata) {
36105
EXPECT_FALSE(result.lastModified.empty());
37106
}
38107

108+
// Test that a failed streaming request carries its error body, against a real
109+
// HTTPS endpoint. httpbin's /status/418 answers non-2xx with a body, which is
110+
// exactly the shape SseParser cannot turn into events.
111+
112+
class StreamErrorBodyLiveTest : public ::testing::Test {
113+
protected:
114+
void SetUp() override { https::Socket::platform_init(); }
115+
};
116+
117+
// ON THE LIBRARY'S OWN DEFAULTS, AND THE READ TIMEOUT IS PART OF THE
118+
// OBSERVATION RATHER THAN A SAFETY NET.
119+
//
120+
// The first form of this test set `keepAlive = false`, "so the server closes
121+
// and the read loop ends". That comment was the defect: `send_stream` had no
122+
// branch for a declared `Content-Length`, so a response that was not chunked
123+
// was read until the connection closed — and on the defaults the server does
124+
// not close. The body arrived, one full `readTimeoutMs` late.
125+
//
126+
// keepAlive = false status=418 body=135 elapsed 1370 ms
127+
// keepAlive = true status=418 body=135 elapsed 9379 ms (timeout 8000)
128+
//
129+
// So the configuration under test is the default one, and the elapsed time is
130+
// asserted. A test that closed the connection to make the loop end would be
131+
// examining the one arrangement in which the defect does not appear.
132+
TEST_F(StreamErrorBodyLiveTest, FailedStreamKeepsTheErrorBody) {
133+
https::HttpClientConfig cfg;
134+
cfg.connectTimeoutMs = 15000;
135+
cfg.readTimeoutMs = 30000;
136+
// keepAlive is left at its default, which is true.
137+
https::HttpClient client(cfg);
138+
139+
https::HttpRequest req;
140+
req.method = https::Method::GET;
141+
req.url = "https://httpbin.org/status/418";
142+
143+
int events = 0;
144+
const auto started = std::chrono::steady_clock::now();
145+
auto res = client.send_stream(req, [&](const https::SseEvent&) {
146+
++events;
147+
return true;
148+
});
149+
const auto elapsed = std::chrono::duration_cast<std::chrono::milliseconds>(
150+
std::chrono::steady_clock::now() - started).count();
151+
152+
EXPECT_EQ(res.statusCode, 418);
153+
EXPECT_EQ(events, 0) << "an error document is not an event stream";
154+
EXPECT_FALSE(res.body.empty()) << "error body was dropped";
155+
EXPECT_NE(res.body.find("teapot"), std::string::npos);
156+
// Generous against a slow runner and still an order of magnitude below the
157+
// read timeout, which is what the defect consumed.
158+
EXPECT_LT(elapsed, 15000)
159+
<< "the body arrived after " << elapsed
160+
<< " ms; a declared Content-Length was not honoured and the reader "
161+
"waited for a close that keep-alive was never going to bring";
162+
}
163+
164+
// The success path, on the framing an event stream actually uses. A 2xx is not
165+
// captured, so `body` stays empty and the bytes reach the parser — and it must
166+
// still return promptly, since the chunked branch is the one this change did
167+
// not restructure.
168+
TEST_F(StreamErrorBodyLiveTest, AChunkedSuccessIsUnchangedAndPrompt) {
169+
https::HttpClientConfig cfg;
170+
cfg.connectTimeoutMs = 15000;
171+
cfg.readTimeoutMs = 30000;
172+
https::HttpClient client(cfg);
173+
174+
https::HttpRequest req;
175+
req.method = https::Method::GET;
176+
req.url = "https://httpbin.org/stream/3";
177+
178+
const auto started = std::chrono::steady_clock::now();
179+
auto res = client.send_stream(req, [](const https::SseEvent&) { return true; });
180+
const auto elapsed = std::chrono::duration_cast<std::chrono::milliseconds>(
181+
std::chrono::steady_clock::now() - started).count();
182+
183+
EXPECT_EQ(res.statusCode, 200);
184+
EXPECT_TRUE(res.body.empty()) << "a 2xx body is not captured; it is the caller's stream";
185+
EXPECT_LT(elapsed, 15000) << "the chunked reader did not terminate promptly";
186+
}
187+
39188
// Test download_to_file against a real HTTPS endpoint.
40189
// Uses httpbin.org which returns known-size responses.
41190

0 commit comments

Comments
 (0)