Elixir Streams to process large HTTP responses on the fly

Alvise Susmel About

March 27, 2019

X Follow Button

This is the first of a two-part article where we see a handy way of handling async HTTP responses.

  • In this first part we see how make an Elixir Stream out of a large HTTP response.
  • In the second part we will extend the implementation so we can download a large text file, processing lines on the fly.

First and foremost … WHY?

Why should we handle HTTP responses with Streams, when we can just simply use something like HTTPoison.get to receive a response with the data we need?

We already saw howHTTPoison , by default, saves the whole HTTP response into memory. We’ve also seen that this can be avoided using asynchronous requests, but this forces use everything to handle low level HTTPoison async process messages.

Wouldn’t be great to be able to write something like this?

HTTPStream.get("https://.../large_file.csv", :line)
|> Stream.filter ...
|> Stream.map ...
|> Enum.take(10)

Elixir

Copy

Just few lines and a wonderful pipeline syntax to process a large text file on the fly. But we could also use streams to download and compress binary files, like in this way

HTTPStream.get("https://.../large_image.tiff")
|> StreamGzip.gzip
|> Stream.into(File.stream!("image.tiff.gz"))
|> Stream.run

Elixir

Copy

This approach brings many advantages:

  • We can take advantage of Elixir Streams to easily create beautiful pipelines, getting code clarity and reusability.
  • A big HTTP response is divided into chunks, avoiding to incur into memory issues
  • We can process a file of any dimension on the fly. We can even process the first few hundred lines of a large CSV file, without needing to download all of it.

It turns out that thanks to native Elixir Stream’s functions, it’s not that hard to create our HTTP Stream.

First example – a large image file

Before start coding our implementation, we need to find an easy example to play with. We can start with the example of HTTPoison Async Request article, where we downloaded a large image. But this time the goal is to do it just using Elixir streams.

The Whirlpool Galaxy

The original image is a TIFF file of 204.9Mb, which is enough to do a first test of our implementation. The URL we are going to use is: https://www.spacetelescope.org/static/archives/images/original/heic0506a.tif

Implementation

Stream.resource/3

With HTTPoison Async Request we have a good starting point: instead of getting the whole response with a struct in memory, the HTTP response is divided in chunks, sent one by one to the process’ mailbox.

Stream.resource/3 is exactly what we need to wrap HTTPoison.

Stream.resource(
  start_fun,
  next_fun,
  end_fun
)

Elixir

Copy

start_fun

We now define a module called HTTPStream and a function get(url), in which we build our stream and return. The first function passed to Stream.resource is the start_fun, in which we describe how to start the enumeration, in our case making an async HTTP request.

defmodule HTTPStream do

def get(url) do
    Stream.resource(

#start_fun
      fn ->
        HTTPoison.get!(
           url,  %{},
           [stream_to: self(), async: :once]
        )
      end,

next_fun,
     end_fun
    )
  end
end

Elixir

Copy

When passing the options [stream_to: self(), async: once], HTTPoison.get! returns immediately a `%HTTPoison.AsyncResponse{id: #Reference<...>} struct, which is then passed to next_fun.

Remember that a stream is lazy, the functions passed to Stream.resource/3 won’t run immediately. In this way we can build a stream pipeline that actually makes the HTTP request only when a function tries to enumerate it.

next_fun – AsyncStatus

In the second function, next_fun, we receive and handle the data coming from the asynchronous HTTP response. We now write just the first part to handle the status code message, %HTTPoison.AsyncStatus{}.

# next_fun
fn %HTTPoison.AsyncResponse{id: id}=resp ->
  receive do
    %HTTPoison.AsyncStatus{id: ^id, code: code}->
      IO.inspect(code, label: "Status code: ")
      {:halt, resp}
  after
    5_000 -> raise "receive timeout"
  end
end

Elixir

Copy

The next_fun we provide expects the async response %HTTPoison.AsyncResponse{} struct returned by start_fun. We used its id to selectively receive only the response’s messages. At the moment we’ve implemented just the status code part, so we can test it straightaway seeing if it works.

  • next_fun must return a tuple, this can be {[...], resp} when we want to pass elements to the pipeline, or {:halt, resp} when we want to stop the enumeration.
  • %HTTPoison.AsyncStatus{} is the first message we receive, so we print the code and return {:halt, resp}, since we just want to test this part and stop the enumeration.
  • The end_fun is called when the stream is halted. In this function we clean up the resources closing the connection. In our case we stop the asynchronous response calling :hackney.stop_async(resp.id).

It’s time to see quickly how this first part works on iex

$ iex -S mix
Erlang/OTP 21 ...
Interactive Elixir (1.8.0)

iex> image_url = "https://www.spacetelescope.org/static/archives/images/original/heic0506a.tif"

iex> image_url
...> |> HTTPStream.get()
...> |> Stream.run
Status code: : 200
:ok

Bash

Copy

Great it, works! 🎉 It gets and prints the status code and stops, without downloading the whole file.

AsyncHeaders, AsyncChunk and AsyncEnd

Let’s now implement the other Async messages.

#next_fun
fn %HTTPoison.AsyncResponse{id: id}=resp->
  receive do
    %HTTPoison.AsyncStatus{id: ^id, code: code}->
      IO.inspect(code, label: "STATUS: ")
      HTTPoison.stream_next(resp)
      {[], resp}
    %HTTPoison.AsyncHeaders{id: ^id, headers: headers}->
      IO.inspect(headers, label: "HEADERS: ")
      HTTPoison.stream_next(resp)
      {[], resp}
    %HTTPoison.AsyncChunk{id: ^id, chunk: chunk}->
      HTTPoison.stream_next(resp)
      {[chunk], resp}
    %HTTPoison.AsyncEnd{id: ^id}->
      {:halt, resp}
  end
end

Elixir

Copy

%HTTPoison.AsyncStatus{}

We’ve seen how we handle this message. Instead of halting the stream we now request the next message using HTTPoison.stream_next(resp) and, since at this stage we don’t have any data to emit, we return a {[], resp} tuple. resp is the accumulator, which is then passed to next_fun the next time is called.

%HTTPoison.AsyncHeaders{}

Similar of what we’ve done for the status code, we print the headers, we ask HTTPoison to send the next message to our the process mailbox and we return a tuple with an empty list, since there is no data we need to emit.

%HTTPoison.AsyncChunk{}

These are the messages containing the actual response’s body, divided in chunks. One message for each small chunk. Like previously, we ask for the next message but this time we emit the chunk returning {[chunk], resp}.

%HTTPoison.AsyncEnd{}

We receive this message when reached the end of the HTTP response. It’s now time to halt the enumeration.

Time for a first ride 🏎

iex> HTTPStream.get(large_tiff_image_url)
#Function<55.131689479/2 in Stream.resource/3>

Elixir

Copy

At first, we see HTTPStream.get(url)returns a stream and no request is run at the moment.

Let’s also start the Erlang observer to monitor the allocated memory

iex> :observer.start

iex> large_tiff_image_url \
...> HTTPStream.get() \
...> |> Stream.into(File.stream!("image.tif"))
...> |> Stream.run

STATUS: : 200
HEADERS: : [\
  {"Server", "nginx/1.13.7"},\
  {"Content-Type", "image/tiff"},\
  {"Content-Length", "214777324"},\
  ...\
]
:ok

Elixir

Copy

This time we have are just interested on writing to a file, so we use Stream.run at the end to run the pipeline. All the emitted chunks are caught by Stream.into and written to “image.tif”.

We also see on the observer that the allocated memory by the download stream is minimal.

Compression? Just a line of code

And if we want to compress the file while downloading it? Do we have to change the whole implementation? No!

large_tiff_image_url
|> HTTPStream.get()
|> StreamGzip.gzip()
|> Stream.into(File.stream!("image.tif.gz"))
|> Stream.run

Elixir

Copy

Thanks to streams high composability, we just need to add a new stage into the pipeline. Using the StreamGzip library, we compress the chunks coming from HTTPStream.get and save them into “image.tif.gz”.

What’s next? Text processing!

We have seen how the approach of wrapping HTTPoison with an Elixir stream brings many advantages. In this part we’ve just seen how to download a binary file, compress it and save it locally.

In the next part we will see how to refactor our implementation to treat lines of text, instead of just chunks, so we can process huge text files on the fly avoiding to impact memory.

Share this:

Disqus Recommendations

We were unable to load Disqus Recommendations. If you are a moderator please see our troubleshooting guide.

  • 7 years ago
  • 2 comments

We setup the AWS account, configure ExAws, put, list, get and delete objects. …

  • 7 years ago
  • 6 comments

Make requests with HTTPoison is easy, but the response is held in …

  • 7 years ago
  • 1 comment

After a quick intro to containers and images, we see how easy it is to run …

  • 7 years ago
  • 26 comments

A step-by-step tutorial we see in depth how to build a Phoenix app from scratch, …

  • 7 years ago
  • 2 comments

Come up with a workaround to make LiveView play together with a JavaScript …

  • 7 years ago
  • 8 comments

Phoenix LiveView pushstate support bring the ability to change the URL without …

  • 7 years ago
  • 7 comments

Part 1 – Elixir Stream to process large HTTP responses on the fly Part …

  • 6 years ago
  • 2 comments

With LiveView JavaScript hooks it's now really easy to do JS interop. In this …

tempest.services.disqus.com

tempest.services.disqus.com is blocked

This page has been blocked by an extension

  • Try disabling your extensions.

ERR_BLOCKED_BY_CLIENT

Reload

This page has been blocked by an extension

Disqus Comments

We were unable to load Disqus. If you are a moderator please see our troubleshooting guide.

G

Join the discussion…

Comment

Log in with
or sign up with Disqus or pick a name

Disqus is a discussion network

  • Don't be a jerk or do anything illegal. Everything is easier that way.

Read full terms and conditions

This comment platform is hosted by Disqus, Inc. I authorize Disqus and its affiliates to:

  • Use, sell, and share my information to enable me to use its comment services and for marketing purposes, including cross-context behavioral advertising, as described in our Terms of Service and Privacy Policy, including supplementing that information with other data about me, such as my browsing and location data.
  • Contact me or enable others to contact me by email with offers for goods or services
  • Process any sensitive personal information that I submit in a comment. See our Privacy Policy for more information

Acknowledge I am 18 or older

  • 2

  • Discussion Favorited!

Favoriting means this is a discussion worth sharing. It gets shared to your followers' Disqus feeds, and gives the creator kudos!

Find More Discussions

Share

Thanks, what does `end_fun` look like?

see more

In the end function you usually close or release the resources, like a file descriptor.

see more

Show more replies

Load more comments

tempest.services.disqus.com

tempest.services.disqus.com is blocked

This page has been blocked by an extension

  • Try disabling your extensions.

ERR_BLOCKED_BY_CLIENT

Reload

This page has been blocked by an extension

Elixir

Nerves powered Vision – Deploy YOLOv8 on RPi5 with…

Alvise Susmel

Sep 5, 202514 sec read

Elixir

Building a YOLOX Plate Detector – Setup, Fine-Tuning, Metrics,…

Alvise Susmel

Aug 29, 20253 min read

Elixir

Fine-Tuning YOLO to Watch Soccer Matches

Alvise Susmel

Jul 17, 20255 min read

Twitter Widget Iframe