Skip to content

Commit 4e9883e

Browse files
authored
Merge pull request #2 from gsmlg-dev/develop
2 parents 14622d5 + f6ad9f5 commit 4e9883e

File tree

8 files changed

+1154
-164
lines changed

8 files changed

+1154
-164
lines changed

lib/http.ex

Lines changed: 317 additions & 109 deletions
Large diffs are not rendered by default.

lib/http/config.ex

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
defmodule HTTP.Config do
2+
@moduledoc """
3+
Configuration module for HTTP fetch library.
4+
5+
This module centralizes configuration values that control HTTP client behavior,
6+
including timeouts, streaming thresholds, and other runtime parameters.
7+
8+
## Configuration Values
9+
10+
All configuration values are compile-time constants defined as module attributes.
11+
To customize these values, modify this module and recompile the library.
12+
13+
### Streaming Configuration
14+
15+
- `streaming_threshold/0` - Size threshold (in bytes) above which responses are automatically streamed.
16+
Default: 5MB (5,000,000 bytes)
17+
18+
### Timeout Configuration
19+
20+
- `default_request_timeout/0` - Maximum time (in milliseconds) to wait for a complete HTTP response.
21+
Default: 120 seconds (120,000 ms)
22+
23+
- `streaming_timeout/0` - Maximum time (in milliseconds) to wait for streaming operations.
24+
Default: 60 seconds (60,000 ms)
25+
26+
## Usage
27+
28+
# Check if response should be streamed
29+
if content_length > HTTP.Config.streaming_threshold() do
30+
# Stream the response
31+
end
32+
33+
# Use default timeout
34+
receive do
35+
{:http, response} -> handle_response(response)
36+
after
37+
HTTP.Config.default_request_timeout() -> :timeout
38+
end
39+
"""
40+
41+
@streaming_threshold 5_000_000
42+
@default_request_timeout 120_000
43+
@streaming_timeout 60_000
44+
45+
@doc """
46+
Returns the size threshold (in bytes) for automatic streaming.
47+
48+
Responses with Content-Length greater than this value will be automatically
49+
streamed to avoid loading large files into memory.
50+
51+
Default: 5MB (5,000,000 bytes)
52+
53+
## Examples
54+
55+
iex> HTTP.Config.streaming_threshold()
56+
5_000_000
57+
"""
58+
@spec streaming_threshold() :: 5_000_000
59+
def streaming_threshold, do: @streaming_threshold
60+
61+
@doc """
62+
Returns the default request timeout in milliseconds.
63+
64+
This is the maximum time the HTTP client will wait for a complete response
65+
after sending a request.
66+
67+
Default: 120 seconds (120,000 milliseconds)
68+
69+
## Examples
70+
71+
iex> HTTP.Config.default_request_timeout()
72+
120_000
73+
"""
74+
@spec default_request_timeout() :: 120_000
75+
def default_request_timeout, do: @default_request_timeout
76+
77+
@doc """
78+
Returns the streaming timeout in milliseconds.
79+
80+
This is the maximum time to wait for streaming operations, including:
81+
- Waiting for stream chunks in `collect_stream/2`
82+
- Waiting for messages in the stream loop
83+
84+
Default: 60 seconds (60,000 milliseconds)
85+
86+
## Examples
87+
88+
iex> HTTP.Config.streaming_timeout()
89+
60_000
90+
"""
91+
@spec streaming_timeout() :: 60_000
92+
def streaming_timeout, do: @streaming_timeout
93+
end

lib/http/form_data.ex

Lines changed: 22 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -128,8 +128,9 @@ defmodule HTTP.FormData do
128128
Converts FormData to HTTP body content with appropriate encoding.
129129
130130
Returns {:url_encoded, body} for regular forms or {:multipart, body, boundary} for multipart.
131+
The multipart body is returned as iodata for memory efficiency with large file uploads.
131132
"""
132-
@spec to_body(t()) :: {:url_encoded, String.t()} | {:multipart, String.t(), String.t()}
133+
@spec to_body(t()) :: {:url_encoded, String.t()} | {:multipart, iodata(), String.t()}
133134
def to_body(%__MODULE__{parts: parts} = form) do
134135
has_file? =
135136
Enum.any?(parts, fn
@@ -184,33 +185,38 @@ defmodule HTTP.FormData do
184185

185186
body_parts =
186187
parts
187-
|> Enum.map(fn
188+
|> Enum.flat_map(fn
188189
{:field, name, value} ->
189-
encode_multipart_field(boundary, name, value)
190+
[encode_multipart_field(boundary, name, value), "\r\n"]
190191

191192
{:file, name, filename, content_type, %File.Stream{} = stream} ->
192-
encode_multipart_file_stream(boundary, name, filename, content_type, stream)
193+
[encode_multipart_file_stream(boundary, name, filename, content_type, stream), "\r\n"]
193194

194195
{:file, name, filename, content_type, content} ->
195-
encode_multipart_file_content(boundary, name, filename, content_type, content)
196+
[encode_multipart_file_content(boundary, name, filename, content_type, content), "\r\n"]
196197
end)
197198

198-
body = Enum.join(body_parts, "\r\n") <> "\r\n--" <> boundary <> "--\r\n"
199+
# Build as iodata list for memory efficiency
200+
body = [body_parts, "--", boundary, "--\r\n"]
199201

200202
{:multipart, body, boundary}
201203
end
202204

203205
defp encode_multipart_field(boundary, name, value) do
204-
"--#{boundary}\r\n" <>
205-
"Content-Disposition: form-data; name=\"#{name}\"\r\n\r\n" <>
206-
"#{value}"
206+
[
207+
"--#{boundary}\r\n",
208+
"Content-Disposition: form-data; name=\"#{name}\"\r\n\r\n",
209+
value
210+
]
207211
end
208212

209213
defp encode_multipart_file_content(boundary, name, filename, content_type, content) do
210-
"--#{boundary}\r\n" <>
211-
"Content-Disposition: form-data; name=\"#{name}\"; filename=\"#{filename}\"\r\n" <>
212-
"Content-Type: #{content_type}\r\n\r\n" <>
214+
[
215+
"--#{boundary}\r\n",
216+
"Content-Disposition: form-data; name=\"#{name}\"; filename=\"#{filename}\"\r\n",
217+
"Content-Type: #{content_type}\r\n\r\n",
213218
content
219+
]
214220
end
215221

216222
defp encode_multipart_file_stream(
@@ -220,7 +226,9 @@ defmodule HTTP.FormData do
220226
content_type,
221227
%File.Stream{} = stream
222228
) do
223-
content = stream |> Enum.into("")
224-
encode_multipart_file_content(boundary, name, filename, content_type, content)
229+
# Convert stream to list of binaries (iodata) instead of concatenating into single binary
230+
# This avoids loading the entire file into memory as one large binary
231+
content_chunks = Enum.to_list(stream)
232+
encode_multipart_file_content(boundary, name, filename, content_type, content_chunks)
225233
end
226234
end

lib/http/request.ex

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -124,7 +124,8 @@ defmodule HTTP.Request do
124124
content_type = to_charlist("multipart/form-data; boundary=#{boundary}")
125125
# Add boundary header
126126
updated_headers = headers ++ [{~c"Content-Type", to_charlist(content_type)}]
127-
{url, updated_headers, to_charlist(body)}
127+
# Convert iodata to charlist efficiently
128+
{url, updated_headers, iodata_to_charlist(body)}
128129
end
129130

130131
# For regular string/charlist bodies
@@ -143,6 +144,15 @@ defmodule HTTP.Request do
143144
defp to_body(body) when is_list(body), do: body
144145
defp to_body(other), do: String.to_charlist(to_string(other))
145146

147+
# Efficiently converts iodata (nested list of binaries) to charlist
148+
# This is used for multipart form data to minimize intermediate copies
149+
# The iodata structure is flattened once instead of concatenating strings multiple times
150+
defp iodata_to_charlist(iodata) do
151+
iodata
152+
|> IO.iodata_to_binary()
153+
|> String.to_charlist()
154+
end
155+
146156
@spec add_default_user_agent([{String.t(), String.t()}]) :: [{String.t(), String.t()}]
147157
defp add_default_user_agent(headers) do
148158
# Check if user agent is already provided (case-insensitive)

lib/http/response.ex

Lines changed: 25 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -133,8 +133,7 @@ defmodule HTTP.Response do
133133
{:stream_error, ^stream, _reason} ->
134134
acc
135135
after
136-
# 60 second timeout
137-
60_000 -> acc
136+
HTTP.Config.streaming_timeout() -> acc
138137
end
139138
end
140139

@@ -239,33 +238,31 @@ defmodule HTTP.Response do
239238
"""
240239
@spec write_to(t(), String.t()) :: :ok | {:error, term()}
241240
def write_to(%__MODULE__{} = response, file_path) do
242-
try do
243-
# Ensure the directory exists
244-
file_path
245-
|> Path.dirname()
246-
|> File.mkdir_p!()
247-
248-
case response do
249-
%{body: body, stream: nil} when is_binary(body) or is_list(body) ->
250-
# Non-streaming response
251-
binary_body =
252-
if is_list(body), do: IO.iodata_to_binary(body), else: body
253-
254-
File.write!(file_path, binary_body)
255-
:ok
256-
257-
%{body: _body, stream: stream} when is_pid(stream) ->
258-
# Streaming response - collect and write
259-
write_stream_to_file(response, file_path)
260-
261-
_ ->
262-
# Empty or nil body
263-
File.write!(file_path, "")
264-
:ok
265-
end
266-
rescue
267-
error -> {:error, error}
241+
# Ensure the directory exists
242+
file_path
243+
|> Path.dirname()
244+
|> File.mkdir_p!()
245+
246+
case response do
247+
%{body: body, stream: nil} when is_binary(body) or is_list(body) ->
248+
# Non-streaming response
249+
binary_body =
250+
if is_list(body), do: IO.iodata_to_binary(body), else: body
251+
252+
File.write!(file_path, binary_body)
253+
:ok
254+
255+
%{body: _body, stream: stream} when is_pid(stream) ->
256+
# Streaming response - collect and write
257+
write_stream_to_file(response, file_path)
258+
259+
_ ->
260+
# Empty or nil body
261+
File.write!(file_path, "")
262+
:ok
268263
end
264+
rescue
265+
error -> {:error, error}
269266
end
270267

271268
defp write_stream_to_file(response, file_path) do

lib/http/telemetry.ex

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ defmodule HTTP.Telemetry do
1717
1818
**`[:http_fetch, :request, :start]`** - Emitted when a request begins
1919
20-
- Measurements: `%{start_time: integer}` (milliseconds)
20+
- Measurements: `%{start_time: integer}` (microseconds)
2121
- Metadata: `%{method: atom, url: URI.t(), headers: HTTP.Headers.t()}`
2222
2323
**`[:http_fetch, :request, :stop]`** - Emitted when a request completes successfully
@@ -143,7 +143,7 @@ defmodule HTTP.Telemetry do
143143
"""
144144
@spec request_start(String.t(), URI.t(), HTTP.Headers.t()) :: :ok
145145
def request_start(method, url, headers) do
146-
measurements = %{start_time: System.system_time(:millisecond)}
146+
measurements = %{start_time: System.system_time(:microsecond)}
147147

148148
metadata = %{
149149
method: method,

test/http/form_data_test.exs

Lines changed: 25 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -81,10 +81,15 @@ defmodule HTTP.FormDataTest do
8181
|> Map.put(:boundary, "test-boundary")
8282

8383
assert {:multipart, body, "test-boundary"} = HTTP.FormData.to_body(form)
84-
assert body =~ "Content-Disposition: form-data; name=\"name\""
85-
assert body =~ "John"
86-
assert body =~ "Content-Disposition: form-data; name=\"upload\"; filename=\"test.txt\""
87-
assert body =~ "file content"
84+
# Convert iodata to binary for testing
85+
body_string = IO.iodata_to_binary(body)
86+
assert body_string =~ "Content-Disposition: form-data; name=\"name\""
87+
assert body_string =~ "John"
88+
89+
assert body_string =~
90+
"Content-Disposition: form-data; name=\"upload\"; filename=\"test.txt\""
91+
92+
assert body_string =~ "file content"
8893
end
8994

9095
test "encodes multipart with files" do
@@ -95,10 +100,15 @@ defmodule HTTP.FormDataTest do
95100

96101
assert {:multipart, body, boundary} = HTTP.FormData.to_body(form)
97102
assert is_binary(boundary)
98-
assert body =~ "Content-Disposition: form-data; name=\"description\""
99-
assert body =~ "test file"
100-
assert body =~ "Content-Disposition: form-data; name=\"upload\"; filename=\"test.txt\""
101-
assert body =~ "file content"
103+
# Convert iodata to binary for testing
104+
body_string = IO.iodata_to_binary(body)
105+
assert body_string =~ "Content-Disposition: form-data; name=\"description\""
106+
assert body_string =~ "test file"
107+
108+
assert body_string =~
109+
"Content-Disposition: form-data; name=\"upload\"; filename=\"test.txt\""
110+
111+
assert body_string =~ "file content"
102112
end
103113

104114
test "encodes multipart with file stream" do
@@ -113,8 +123,13 @@ defmodule HTTP.FormDataTest do
113123
|> Map.put(:boundary, "test-boundary")
114124

115125
assert {:multipart, body, "test-boundary"} = HTTP.FormData.to_body(form)
116-
assert body =~ "Content-Disposition: form-data; name=\"upload\"; filename=\"test.txt\""
117-
assert body =~ test_content
126+
# Convert iodata to binary for testing
127+
body_string = IO.iodata_to_binary(body)
128+
129+
assert body_string =~
130+
"Content-Disposition: form-data; name=\"upload\"; filename=\"test.txt\""
131+
132+
assert body_string =~ test_content
118133

119134
File.rm(path)
120135
end

0 commit comments

Comments
 (0)