Skip to content
Open
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
## Unreleased

- Fixed a bug where requests to IPv6 literal hosts would fail.
- Added `max_body_size` and `max_header_size` configuration options and associated errors.

## v5.0.0 - 2025-07-12

Expand Down
73 changes: 72 additions & 1 deletion src/gleam/httpc.gleam
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@ pub type HttpError {
FailedToConnect(ip4: ConnectError, ip6: ConnectError)
/// The response was not received within the configured timeout period.
ResponseTimeout
/// The cumulative bytes of the response header was larger than `max_header_size`.
ResponseHeaderTooLarge
/// The response body was larger than `max_body_size`.
ResponseBodyTooLarge
}

pub type ConnectError {
Expand All @@ -29,6 +33,12 @@ fn default_user_agent() -> #(Charlist, Charlist)
@external(erlang, "gleam_httpc_ffi", "normalise_error")
fn normalise_error(error: Dynamic) -> HttpError

@external(erlang, "gleam_httpc_ffi", "max_size_bytes")
fn erl_max_size_bytes(n: Int) -> ErlMaxSize

@external(erlang, "gleam_httpc_ffi", "max_size_nolimit")
fn erl_max_size_nolimit() -> ErlMaxSize

type ErlHttpOption {
Ssl(List(ErlSslOption))
Autoredirect(Bool)
Expand All @@ -43,6 +53,8 @@ type ErlOption {
BodyFormat(BodyFormat)
SocketOpts(List(SocketOpt))
Ipv6HostWithBrackets(Bool)
MaxHeaderSize(ErlMaxSize)
MaxBodySize(ErlMaxSize)
}

type SocketOpt {
Expand All @@ -61,6 +73,13 @@ type ErlVerifyOption {
VerifyNone
}

type ErlMaxSize

pub type MaxSize {
Bytes(Int)
Nolimit
}

@external(erlang, "httpc", "request")
fn erl_request(
a: Method,
Expand Down Expand Up @@ -136,10 +155,15 @@ pub fn dispatch_bits(
True -> erl_http_options
False -> [Ssl([Verify(VerifyNone)]), ..erl_http_options]
}

let erl_max_header_size = erl_max_size(config.max_header_size)
let erl_max_body_size = erl_max_size(config.max_body_size)
let erl_options = [
BodyFormat(Binary),
SocketOpts([Ipfamily(Inet6fb4)]),
Ipv6HostWithBrackets(True),
MaxHeaderSize(erl_max_header_size),
MaxBodySize(erl_max_body_size),
]

use response <- result.try(
Expand Down Expand Up @@ -187,6 +211,13 @@ pub opaque type Configuration {
/// Timeout for the request in milliseconds.
///
timeout: Int,
/// Maximum total number of bytes allowed for the response headers
/// (cumulative across all header lines).
///
max_header_size: MaxSize,
/// Maximum number of bytes allowed for the response body.
///
max_body_size: MaxSize,
)
}

Expand All @@ -200,7 +231,13 @@ pub opaque type Configuration {
/// request is sent.
///
pub fn configure() -> Configuration {
Builder(verify_tls: True, follow_redirects: False, timeout: 30_000)
Builder(
verify_tls: True,
follow_redirects: False,
timeout: 30_000,
max_header_size: Bytes(10_240),

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's go for 2kb rather than 1kib.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just so you know i set the same default as httpc itself:

https://www.erlang.org/doc/apps/inets/httpc.html#request/5

max_header_size - Maximum total number of bytes allowed for the response headers (cumulative across all header lines). If the server sends headers exceeding this limit, the request fails with {error, {header_too_long, {max, MaxSize}}}.

The atom nolimit disables the check (not recommended).

Default is 10240 (same as the max_header_size configuration for httpd).

max_body_size: Nolimit,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there a reason to ever permit no limit? Surely the same problem exists with the body, and a limit is always desired.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same here, this is the httpc default:

https://www.erlang.org/doc/apps/inets/httpc.html#request/5

max_body_size - Maximum number of bytes allowed for the response body. If the Content-Length of the response exceeds this limit, the request fails with {error, body_too_big}.

The atom nolimit disables the check.

Default is nolimit.

)
}

/// Set whether to verify the TLS certificate of the server.
Expand Down Expand Up @@ -230,6 +267,33 @@ pub fn timeout(config: Configuration, timeout: Int) -> Configuration {
Builder(..config, timeout:)
}

/// Set the maximum size limit in bytes for the combination of all the headers
/// of the response.
///
/// The default is 10_240 bytes.
///
/// If a response's headers are larger than this limit then the
/// `ResponseHeaderTooLarge` error is returned.
pub fn max_header_size(
config: Configuration,
max_header_size: MaxSize,
) -> Configuration {
Builder(..config, max_header_size:)
}

/// Set the maximum size limit in bytes for the body of the response.
///
/// The default is unlimited.
///
/// If a response's body is larger than this limit then the
/// `ResponseBodyTooLarge` error is returned.
pub fn max_body_size(
config: Configuration,
max_body_size: MaxSize,
) -> Configuration {
Builder(..config, max_body_size:)
}

/// Send a HTTP request of unicode data.
///
pub fn dispatch(
Expand Down Expand Up @@ -276,3 +340,10 @@ fn prepare_headers_loop(
}
}
}

fn erl_max_size(max_size: MaxSize) -> ErlMaxSize {
case max_size {
Bytes(bytes) -> erl_max_size_bytes(bytes)
Nolimit -> erl_max_size_nolimit()
}
}
9 changes: 8 additions & 1 deletion src/gleam_httpc_ffi.erl
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
-module(gleam_httpc_ffi).
-export([default_user_agent/0, normalise_error/1]).
-export([default_user_agent/0, normalise_error/1, max_size_bytes/1, max_size_nolimit/0]).

normalise_error(Error = {failed_connect, Opts}) ->
Ipv6 = case lists:keyfind(inet6, 1, Opts) of
Expand All @@ -13,6 +13,10 @@ normalise_error(Error = {failed_connect, Opts}) ->
{failed_to_connect, normalise_ip_error(Ipv4), normalise_ip_error(Ipv6)};
normalise_error(timeout) ->
response_timeout;
normalise_error({header_too_long, {max, _MaxSize}}) ->
response_header_too_large;
normalise_error(body_too_big) ->
response_body_too_large;
normalise_error(Error) ->
erlang:error({unexpected_httpc_error, Error}).

Expand All @@ -30,3 +34,6 @@ default_user_agent() ->
undefined -> "0.0.0"
end,
{"user-agent", "gleam_httpc/" ++ Version}.

max_size_bytes(N) when is_integer(N) -> N.
max_size_nolimit() -> nolimit.
51 changes: 51 additions & 0 deletions test/gleam_httpc_test.gleam
Original file line number Diff line number Diff line change
Expand Up @@ -167,3 +167,54 @@ pub fn timeout_error_test() {
|> httpc.dispatch(req)
== Error(httpc.ResponseTimeout)
}

pub fn max_header_size_success_test() {
let assert Ok(req) = request.to(mock_server.url("/header_size/100"))

let assert Ok(http_resp) =
httpc.configure()
|> httpc.max_header_size(httpc.Bytes(300))
|> httpc.dispatch(req)

assert http_resp.status == 200
}

pub fn max_header_size_error_test() {
let assert Ok(req) = request.to(mock_server.url("/header_size/100"))

assert httpc.configure()
|> httpc.max_header_size(httpc.Bytes(50))
|> httpc.dispatch(req)
== Error(httpc.ResponseHeaderTooLarge)
}

pub fn max_header_size_cumulative_error_test() {
let assert Ok(req) = request.to(mock_server.url("/header_size/300"))

// each ~150-byte header is under the 200-byte limit alone,
// but together they exceed it
assert httpc.configure()
|> httpc.max_header_size(httpc.Bytes(200))
|> httpc.dispatch(req)
== Error(httpc.ResponseHeaderTooLarge)
}

pub fn max_body_size_success_test() {
let assert Ok(req) = request.to(mock_server.url("/body_size/100"))

let assert Ok(http_resp) =
httpc.configure()
|> httpc.max_body_size(httpc.Bytes(200))
|> httpc.dispatch(req)

assert http_resp.status == 200
}

pub fn max_body_size_error_test() {
let assert Ok(req) = request.to(mock_server.url("/body_size/100"))

assert httpc.configure()
|> httpc.max_body_size(httpc.Bytes(50))
|> httpc.dispatch(req)
== Error(httpc.ResponseBodyTooLarge)
}
19 changes: 19 additions & 0 deletions test/mock_server.gleam
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import gleam/int
import gleam/json
import gleam/list
import gleam/result
import gleam/string
import mist.{type Connection, type ResponseData}

pub type MockResponse {
Expand Down Expand Up @@ -82,6 +83,24 @@ fn http_handler(req: Request(Connection)) -> Response(ResponseData) {
test_response(req)
}

["header_size", size_bytes_str], _ -> {
let assert Ok(size_bytes) = int.parse(size_bytes_str)
let half = size_bytes / 2

response.new(200)
|> response.set_header("payload-a", string.repeat("a", half))
|> response.set_header("payload-b", string.repeat("b", size_bytes - half))
|> response.set_body(mist.Bytes(bytes_tree.new()))
}

["body_size", size_bytes_str], _ -> {
let assert Ok(size_bytes) = int.parse(size_bytes_str)
let body = string.repeat("a", size_bytes)

response.new(200)
|> response.set_body(mist.Bytes(bytes_tree.from_string(body)))
}

["redirect"], _ -> {
let host =
request.get_header(req, "host")
Expand Down