From 291ae51997eb7acfb082b346f0be70c77af27df9 Mon Sep 17 00:00:00 2001 From: Kevin Schweikert Date: Sat, 24 Oct 2026 17:48:08 +0200 Subject: [PATCH] feat: add max_*_size options inets 9.7.2 introduced two new options: - max_body_size - max_header_size --- CHANGELOG.md | 1 + src/gleam/httpc.gleam | 73 ++++++++++++++++++++++++++++++++++++- src/gleam_httpc_ffi.erl | 9 ++++- test/gleam_httpc_test.gleam | 51 ++++++++++++++++++++++++++ test/mock_server.gleam | 19 ++++++++++ 5 files changed, 151 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4a4ae39..e09127b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/src/gleam/httpc.gleam b/src/gleam/httpc.gleam index 7619fe8..6af3707 100644 --- a/src/gleam/httpc.gleam +++ b/src/gleam/httpc.gleam @@ -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 { @@ -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) @@ -43,6 +53,8 @@ type ErlOption { BodyFormat(BodyFormat) SocketOpts(List(SocketOpt)) Ipv6HostWithBrackets(Bool) + MaxHeaderSize(ErlMaxSize) + MaxBodySize(ErlMaxSize) } type SocketOpt { @@ -61,6 +73,13 @@ type ErlVerifyOption { VerifyNone } +type ErlMaxSize + +pub type MaxSize { + Bytes(Int) + Nolimit +} + @external(erlang, "httpc", "request") fn erl_request( a: Method, @@ -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( @@ -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, ) } @@ -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), + max_body_size: Nolimit, + ) } /// Set whether to verify the TLS certificate of the server. @@ -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( @@ -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() + } +} diff --git a/src/gleam_httpc_ffi.erl b/src/gleam_httpc_ffi.erl index 561cdf0..0d89a5c 100644 --- a/src/gleam_httpc_ffi.erl +++ b/src/gleam_httpc_ffi.erl @@ -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 @@ -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}). @@ -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. diff --git a/test/gleam_httpc_test.gleam b/test/gleam_httpc_test.gleam index 91ce7d1..4ba8862 100644 --- a/test/gleam_httpc_test.gleam +++ b/test/gleam_httpc_test.gleam @@ -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) +} diff --git a/test/mock_server.gleam b/test/mock_server.gleam index 4f277a4..c46c276 100644 --- a/test/mock_server.gleam +++ b/test/mock_server.gleam @@ -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 { @@ -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")