Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 16 additions & 1 deletion docs/api/error-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ enum class ErrorCode {
InvalidChunkFraming,
ConflictingMessageFraming,
UnexpectedEof,
ResponseLimitExceeded,

RedirectLimitExceeded,
MissingRedirectLocation,
Expand Down Expand Up @@ -97,7 +98,7 @@ Rules:
| `ReadTimeout` | Waiting for response bytes exceeded the configured read timeout. |
| `ConnectionClosed` | Peer closure is observed where the operation requires an active connection. |

### HTTP protocol errors
### HTTP protocol / response errors

| Code | Meaning |
| --- | --- |
Expand All @@ -109,6 +110,9 @@ Rules:
| `InvalidChunkFraming` | Chunk delimiters, CRLF, or terminal framing are invalid. |
| `ConflictingMessageFraming` | Response framing metadata is contradictory or unsafe to interpret silently. |
| `UnexpectedEof` | Connection ended before protocol-defined response completion. |
| `ResponseLimitExceeded` | Response parsing would exceed a configured response-head, decoded-body, chunk-line, or trailer resource limit. |

`ResponseLimitExceeded` is intentionally separate from syntax errors: a response may be syntactically valid but exceed the caller's configured in-memory resource budget.

### Redirect errors

Expand Down Expand Up @@ -178,6 +182,17 @@ Examples:
- EOF after a valid close-delimited response body → normal message completion, not an error.
- EOF when a reusable connection is expected but no request is currently active may simply invalidate reuse state internally.

## Resource-limit semantics

Response resource limits are described in `response-limits.md`.

Exceeding a configured limit:

- returns `ResponseLimitExceeded`,
- does not become `MalformedResponse`,
- does not produce a partial successful `Response`,
- does not leave the active connection eligible for reuse.

## Diagnostic Message Function

The library may expose a lightweight function such as:
Expand Down
28 changes: 28 additions & 0 deletions docs/api/public-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ The v1 public surface consists primarily of:
- `Client`
- `Request`
- `Response`
- `ResponseLimits`
- `Headers`
- `Url`
- `Error`
Expand All @@ -44,13 +45,15 @@ classDiagram
class Client
class Request
class Response
class ResponseLimits
class Headers
class Url
class Error
class Result~T~

Client --> Request : executes
Client --> Response : returns
Client --> ResponseLimits : configures
Request --> Headers : contains
Request --> Url : targets
Response --> Headers : contains
Expand All @@ -68,6 +71,7 @@ Responsibilities:
- retain reusable connection state,
- retain client-level timeout configuration,
- retain redirect configuration,
- retain response resource-limit configuration,
- execute sequential HTTP requests,
- expose convenience member functions for common methods.

Expand All @@ -76,6 +80,13 @@ Conceptual interface:
```cpp
namespace cpp_request {

struct ResponseLimits {
std::size_t max_head_bytes;
std::size_t max_body_bytes;
std::size_t max_chunk_line_bytes;
std::size_t max_trailer_bytes;
};

class Client {
public:
Client();
Expand All @@ -95,6 +106,9 @@ public:

void set_follow_redirects(bool enabled);
void set_max_redirects(std::size_t count);

void set_response_limits(ResponseLimits limits) noexcept;
const ResponseLimits& response_limits() const noexcept;
};

} // namespace cpp_request
Expand Down Expand Up @@ -126,6 +140,19 @@ Cross-origin redirects do not forward caller-supplied `Host`, `Authorization`, `

Redirects requiring HTTPS/TLS or another unsupported scheme fail with a structured redirect error rather than being followed.

### Response resource limits

Because v1 responses are fully memory-resident, `Client` applies finite response parsing limits by default:

- response head: 64 KiB,
- decoded body: 64 MiB,
- chunk-size line: 8 KiB,
- chunked trailer section: 64 KiB.

Callers may replace the full configuration with `set_response_limits()`. Exceeding a configured limit returns `ErrorCode::ResponseLimitExceeded` and the active connection is not retained for reuse.

Zero is a real limit rather than an unlimited sentinel. Full enforcement details are defined in `response-limits.md`.

---

## `Request`
Expand Down Expand Up @@ -366,6 +393,7 @@ Expected categories include:
- send failure,
- receive failure,
- malformed HTTP response,
- response resource-limit failure,
- redirect failure.

---
Expand Down
77 changes: 77 additions & 0 deletions docs/api/response-limits.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
# Response Resource Limits

## Purpose

`cpp_request` v1 stores completed response bodies in memory. To keep that design predictable under malformed or hostile peers, response parsing has finite resource limits.

Limits are client configuration, not HTTP syntax rules. Exceeding a configured limit returns `ErrorCode::ResponseLimitExceeded` and the connection is not retained for reuse.

## Public configuration

```cpp
cpp_request::ResponseLimits limits;
limits.max_head_bytes = 64 * 1024;
limits.max_body_bytes = 64 * 1024 * 1024;
limits.max_chunk_line_bytes = 8 * 1024;
limits.max_trailer_bytes = 64 * 1024;

cpp_request::Client client;
client.set_response_limits(limits);
```

`Client::response_limits()` returns the currently configured values.

## Default limits

| Resource | Default |
| --- | ---: |
| Response head, including terminating CRLF CRLF | 64 KiB |
| Decoded response body | 64 MiB |
| One chunk-size line, excluding CRLF | 8 KiB |
| Complete chunked trailer section, including CRLF delimiters | 64 KiB |

The defaults are finite product safeguards rather than protocol maxima. Callers that intentionally accept larger responses can replace them with larger values before issuing a request.

## Enforcement semantics

### Response head

The parser rejects a response once the current status/header block cannot fit within `max_head_bytes`. The check happens before header parsing and before unbounded header storage growth.

Interim responses are checked independently per response head.

### Content-Length body

If a valid `Content-Length` exceeds `max_body_bytes`, parsing fails before reserving the declared body capacity.

A body exactly equal to the configured limit is accepted.

### Close-delimited body

Before appending newly received bytes, the parser verifies that the accumulated body plus the new bytes remain within `max_body_bytes`.

### Chunked body

Each parsed chunk size is checked against the remaining decoded-body budget before chunk payload bytes are appended.

`max_chunk_line_bytes` prevents an unterminated or pathologically long chunk-size/extension line from growing indefinitely.

`max_trailer_bytes` bounds the full trailer section, including each CRLF and the terminal empty line.

## Zero values

Zero is a real limit, not a synonym for unlimited. For example, `max_body_bytes = 0` accepts only responses whose decoded body is empty.

A caller that wants an effectively unbounded field can explicitly set that field to a suitably large `std::size_t` value. The library does not provide a separate unlimited sentinel in v1.

## Error behavior

Resource-limit failures use:

```cpp
ErrorCode::ResponseLimitExceeded
```

They are kept separate from `MalformedResponse`, `InvalidHeader`, and other syntax/framing errors because a response can be syntactically valid while exceeding the caller's configured resource budget.

A request that fails because of a response limit does not leave its connection eligible for reuse.
67 changes: 43 additions & 24 deletions docs/roadmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -291,38 +291,57 @@ Historical reference:

Exit criteria: **complete**.

## 3. HTTP correctness review — 🚧 current work
## 3. HTTP correctness review — ✅ complete

Current hardening scope:
Completed work:

- enforce safe response framing precedence.
- reject `Transfer-Encoding` + `Content-Length` ambiguity before connection reuse.
- reject framing fields where `1xx` / `204` semantics forbid them.
- keep `HEAD` / `304` header-terminated while preserving allowed representation metadata.
- correct `205 Reset Content` framing so unframed responses are close-delimited rather than incorrectly reusable.
- reject actual content in a 205 response.
- validate chunk extension token / quoted-string grammar instead of accepting arbitrary printable bytes.
- retain trailer validation and reject framing-critical trailer fields.
- emit `Content-Length: 0` for empty POST/PUT/PATCH requests while leaving empty GET/HEAD/DELETE unchanged.
- document timeout boundaries: connect budget across endpoint attempts, one write budget across request transmission, read timeout per wait for response progress.
- preserve context-sensitive EOF semantics: incomplete explicit framing is `UnexpectedEof`; valid close-delimited EOF completes normally; timeout never masquerades as EOF.
- enforced safe response framing precedence.
- rejected `Transfer-Encoding` + `Content-Length` ambiguity before connection reuse.
- rejected framing fields where `1xx` / `204` semantics forbid them.
- kept `HEAD` / `304` header-terminated while preserving allowed representation metadata.
- corrected `205 Reset Content` framing so unframed responses are close-delimited rather than incorrectly reusable.
- rejected actual content in a 205 response.
- validated chunk extension token / quoted-string grammar instead of accepting arbitrary printable bytes.
- retained trailer validation and rejected framing-critical trailer fields.
- emitted `Content-Length: 0` for empty POST/PUT/PATCH requests while leaving empty GET/HEAD/DELETE unchanged.
- documented timeout boundaries: connect budget across endpoint attempts, one write budget across request transmission, read timeout per wait for response progress.
- preserved context-sensitive EOF semantics: incomplete explicit framing is `UnexpectedEof`; valid close-delimited EOF completes normally; timeout never masquerades as EOF.
- fixed a dangling `std::string_view` in Transfer-Encoding analysis exposed by MSVC Debug CI.

Exit for this substep:
Historical reference:

- PR #24 — HTTP framing correctness hardening

- framing/status/chunk edge cases have deterministic parser tests.
- serializer framing policy is covered by tests.
- no reviewed HTTP message-boundary ambiguity remains known.
Exit criteria: **complete**.

## 4. Resource-bound review — ⏳ next
## 4. Resource-bound review — 🚧 current work

Because response bodies are memory-resident in v1, document or introduce practical defensive limits where appropriate:
Because response bodies are memory-resident in v1, the parser now receives explicit finite response limits from `Client`.

Current hardening scope:

- public `ResponseLimits` value type stored by-value in `Client`.
- default response-head limit: 64 KiB.
- default decoded-body limit: 64 MiB.
- default chunk-size-line limit: 8 KiB.
- default trailer-section limit: 64 KiB.
- reject oversized `Content-Length` before reserving body capacity.
- bound close-delimited accumulation before append.
- bound chunked decoded body before chunk payload append.
- bound unterminated/pathological chunk-size lines.
- bound aggregate chunked trailer bytes.
- classify limit failures as `ResponseLimitExceeded` rather than malformed HTTP.
- make zero a real limit rather than an implicit unlimited sentinel.
- document the resource-limit contract without adding response streaming.

Exit for this substep:

- response-head growth.
- pathological chunk-size lines.
- excessive trailer/header sections.
- body-size expectations.
- head, body, chunk-line, and trailer limits have deterministic tests.
- a body exactly at the configured limit remains valid.
- `Client` preserves configured limits across move operations.
- limit failures cannot leave the active connection eligible for reuse.

This milestone must not add streaming; it only hardens the frozen in-memory design.
Once this substep merges, v0.8 is complete and the next milestone is v0.9 packaging/benchmarks/examples/documentation.

v0.8 exit criteria:

Expand Down
10 changes: 10 additions & 0 deletions include/cpp_request/client.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

#include <cpp_request/request.hpp>
#include <cpp_request/response.hpp>
#include <cpp_request/response_limits.hpp>
#include <cpp_request/result.hpp>

namespace cpp_request {
Expand Down Expand Up @@ -57,6 +58,14 @@ class Client final {
max_redirects_ = count;
}

void set_response_limits(ResponseLimits limits) noexcept {
response_limits_ = limits;
}

[[nodiscard]] const ResponseLimits& response_limits() const noexcept {
return response_limits_;
}

private:
[[nodiscard]] Result<Response> execute_once(const Request& request);
void close_reusable_connection() noexcept;
Expand All @@ -68,6 +77,7 @@ class Client final {

bool follow_redirects_{true};
std::size_t max_redirects_{10};
ResponseLimits response_limits_{};

std::chrono::milliseconds connect_timeout_{5000};
std::chrono::milliseconds read_timeout_{30000};
Expand Down
2 changes: 2 additions & 0 deletions include/cpp_request/error.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ enum class ErrorCode {
InvalidChunkFraming,
ConflictingMessageFraming,
UnexpectedEof,
ResponseLimitExceeded,

RedirectLimitExceeded,
MissingRedirectLocation,
Expand Down Expand Up @@ -62,6 +63,7 @@ struct Error {
case ErrorCode::InvalidChunkFraming: return "invalid chunk framing";
case ErrorCode::ConflictingMessageFraming: return "conflicting HTTP message framing";
case ErrorCode::UnexpectedEof: return "unexpected end of stream";
case ErrorCode::ResponseLimitExceeded: return "response resource limit exceeded";
case ErrorCode::RedirectLimitExceeded: return "redirect limit exceeded";
case ErrorCode::MissingRedirectLocation: return "redirect location missing";
case ErrorCode::UnsupportedRedirectScheme: return "unsupported redirect scheme";
Expand Down
19 changes: 19 additions & 0 deletions include/cpp_request/response_limits.hpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
#pragma once

#include <cstddef>

namespace cpp_request {

struct ResponseLimits final {
static constexpr std::size_t default_max_head_bytes = 64u * 1024u;
static constexpr std::size_t default_max_body_bytes = 64u * 1024u * 1024u;
static constexpr std::size_t default_max_chunk_line_bytes = 8u * 1024u;
static constexpr std::size_t default_max_trailer_bytes = 64u * 1024u;

std::size_t max_head_bytes{default_max_head_bytes};
std::size_t max_body_bytes{default_max_body_bytes};
std::size_t max_chunk_line_bytes{default_max_chunk_line_bytes};
std::size_t max_trailer_bytes{default_max_trailer_bytes};
};

} // namespace cpp_request
6 changes: 5 additions & 1 deletion src/core/client.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,7 @@ Client::Client(Client&& other) noexcept
reusable_port_(std::exchange(other.reusable_port_, 0)),
follow_redirects_(other.follow_redirects_),
max_redirects_(other.max_redirects_),
response_limits_(other.response_limits_),
connect_timeout_(other.connect_timeout_),
read_timeout_(other.read_timeout_),
write_timeout_(other.write_timeout_) {}
Expand All @@ -186,6 +187,7 @@ Client& Client::operator=(Client&& other) noexcept {
reusable_port_ = std::exchange(other.reusable_port_, 0);
follow_redirects_ = other.follow_redirects_;
max_redirects_ = other.max_redirects_;
response_limits_ = other.response_limits_;
connect_timeout_ = other.connect_timeout_;
read_timeout_ = other.read_timeout_;
write_timeout_ = other.write_timeout_;
Expand Down Expand Up @@ -350,7 +352,9 @@ Result<Response> Client::execute_once(const Request& request_value) {
}
}

detail::http::ResponseParser parser{request_value.method()};
detail::http::ResponseParser parser{
request_value.method(),
response_limits_};
std::array<char, 16 * 1024> read_buffer{};

while (!parser.complete()) {
Expand Down
Loading
Loading