├── test ├── test_helper.exs └── tesla_http_cache_test.exs ├── .formatter.exs ├── .gitignore ├── mix.exs ├── CHANGELOG.md ├── mix.lock ├── lib └── tesla_http_cache.ex ├── README.md └── LICENSE /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | -------------------------------------------------------------------------------- /.formatter.exs: -------------------------------------------------------------------------------- 1 | # Used by "mix format" 2 | [ 3 | inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"] 4 | ] 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /_build 2 | /cover 3 | /deps 4 | /doc 5 | /.fetch 6 | erl_crash.dump 7 | *.ez 8 | *.beam 9 | /config/*.secret.exs 10 | .elixir_ls/ 11 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule TeslaHTTPCache.MixProject do 2 | use Mix.Project 3 | 4 | def project do 5 | [ 6 | app: :tesla_http_cache, 7 | description: "HTTP caching middleware for Tesla", 8 | version: "0.4.0", 9 | elixir: "~> 1.10", 10 | start_permanent: Mix.env() == :prod, 11 | deps: deps(), 12 | docs: [ 13 | main: "readme", 14 | extras: ["README.md", "CHANGELOG.md"] 15 | ], 16 | package: package(), 17 | dialyzer: [plt_add_apps: [:http_cache]], 18 | source_url: "https://github.com/tanguilp/tesla_http_cache" 19 | ] 20 | end 21 | 22 | def application do 23 | [ 24 | extra_applications: [:logger] 25 | ] 26 | end 27 | 28 | defp deps do 29 | [ 30 | {:dialyxir, "~> 1.0", only: :dev, runtime: false}, 31 | {:ex_doc, "~> 0.24", only: :dev, runtime: false}, 32 | {:http_cache, "~> 0.4.0", optional: true}, 33 | {:telemetry, "~> 1.0"}, 34 | {:tesla, "~> 1.4"} 35 | ] 36 | end 37 | 38 | def package() do 39 | [ 40 | licenses: ["Apache-2.0"], 41 | links: %{"GitHub" => "https://github.com/tanguilp/tesla_http_cache"} 42 | ] 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # CHANGELOG 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on Keep a Changelog and this project adheres to Semantic Versioning. 6 | 7 | ## [0.4.0] - 2025-05-17 8 | 9 | ### Added 10 | 11 | - [`TeslaHTTPCache`] `stale-while-revalidate` is now supported 12 | 13 | ### Changed 14 | 15 | - [`TeslaHTTPCache`] Error handling was modified and `TeslaHTTPCache` now always tries to find 16 | a cached response observing the `stale-if-error` directive when it receives an error (in addition 17 | to `500`, `502`, `503` and `504` HTTP error statuses as before) 18 | 19 | ## [0.3.2] - 2025-04-07 20 | 21 | ### Added 22 | 23 | - [`TeslaHTTPCache`] Telemetry events now send the `%Tesla.Env{}` as part of the metadata 24 | 25 | ## [0.3.1] - 2024-05-19 26 | 27 | ### Fixed 28 | 29 | - [`TeslaHTTPCache`] Take query parameters into account when caching 30 | 31 | ## [0.3.0] - 2023-06-22 32 | 33 | ### Changed 34 | 35 | - [`TeslaHTTPCache`] Make `http_cache` an optional depedency 36 | 37 | ## [0.2.0] - 2023-04-25 38 | 39 | ### Changed 40 | 41 | - [`TeslaHTTPCache`] Update to use `http_cache` `0.2.0` 42 | - [`TeslaHTTPCache`] Options are now a map (was previously a keyword list) 43 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "cowlib": {:hex, :cowlib, "2.15.0", "3c97a318a933962d1c12b96ab7c1d728267d2c523c25a5b57b0f93392b6e9e25", [:make, :rebar3], [], "hexpm", "4f00c879a64b4fe7c8fcb42a4281925e9ffdb928820b03c3ad325a617e857532"}, 3 | "dialyxir": {:hex, :dialyxir, "1.4.5", "ca1571ac18e0f88d4ab245f0b60fa31ff1b12cbae2b11bd25d207f865e8ae78a", [:mix], [{:erlex, ">= 0.2.7", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "b0fb08bb8107c750db5c0b324fa2df5ceaa0f9307690ee3c1f6ba5b9eb5d35c3"}, 4 | "earmark_parser": {:hex, :earmark_parser, "1.4.26", "f4291134583f373c7d8755566122908eb9662df4c4b63caa66a0eabe06569b0a", [:mix], [], "hexpm", "48d460899f8a0c52c5470676611c01f64f3337bad0b26ddab43648428d94aabc"}, 5 | "erlex": {:hex, :erlex, "0.2.7", "810e8725f96ab74d17aac676e748627a07bc87eb950d2b83acd29dc047a30595", [:mix], [], "hexpm", "3ed95f79d1a844c3f6bf0cea61e0d5612a42ce56da9c03f01df538685365efb0"}, 6 | "ex_doc": {:hex, :ex_doc, "0.28.5", "3e52a6d2130ce74d096859e477b97080c156d0926701c13870a4e1f752363279", [:mix], [{:earmark_parser, "~> 1.4.19", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.14", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1", [hex: :makeup_erlang, repo: "hexpm", optional: false]}], "hexpm", "d2c4b07133113e9aa3e9ba27efb9088ba900e9e51caa383919676afdf09ab181"}, 7 | "http_cache": {:hex, :http_cache, "0.4.0", "22b6e80e2ad097a3a3454ae3201cddc79fef313feaac655706d6261a8a85373e", [:rebar3], [{:cowlib, "~> 2.0", [hex: :cowlib, repo: "hexpm", optional: false]}, {:http_cache_store_behaviour, "~> 0.3.0", [hex: :http_cache_store_behaviour, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "3d2cd4b310b1776ff7d0df75756d0167a9bc55d02c94810518eb61db2c08b004"}, 8 | "http_cache_store_behaviour": {:hex, :http_cache_store_behaviour, "0.3.0", "d94116da52eb05a065c63b0d10baeb58b394c23478d7107aaadd270bef577ee5", [:rebar3], [], "hexpm", "45ecd47a0445364141c6e73ce6a64aca478f4473f9af0f509d8e4b4ae43640bd"}, 9 | "makeup": {:hex, :makeup, "1.1.0", "6b67c8bc2882a6b6a445859952a602afc1a41c2e08379ca057c0f525366fc3ca", [:mix], [{:nimble_parsec, "~> 1.2.2 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "0a45ed501f4a8897f580eabf99a2e5234ea3e75a4373c8a52824f6e873be57a6"}, 10 | "makeup_elixir": {:hex, :makeup_elixir, "0.16.0", "f8c570a0d33f8039513fbccaf7108c5d750f47d8defd44088371191b76492b0b", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "28b2cbdc13960a46ae9a8858c4bebdec3c9a6d7b4b9e7f4ed1502f8159f338e7"}, 11 | "makeup_erlang": {:hex, :makeup_erlang, "0.1.1", "3fcb7f09eb9d98dc4d208f49cc955a34218fc41ff6b84df7c75b3e6e533cc65f", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "174d0809e98a4ef0b3309256cbf97101c6ec01c4ab0b23e926a9e17df2077cbb"}, 12 | "mime": {:hex, :mime, "2.0.3", "3676436d3d1f7b81b5a2d2bd8405f412c677558c81b1c92be58c00562bb59095", [:mix], [], "hexpm", "27a30bf0db44d25eecba73755acf4068cbfe26a4372f9eb3e4ea3a45956bff6b"}, 13 | "nimble_parsec": {:hex, :nimble_parsec, "1.2.3", "244836e6e3f1200c7f30cb56733fd808744eca61fd182f731eac4af635cc6d0b", [:mix], [], "hexpm", "c8d789e39b9131acf7b99291e93dae60ab48ef14a7ee9d58c6964f59efb570b0"}, 14 | "telemetry": {:hex, :telemetry, "1.3.0", "fedebbae410d715cf8e7062c96a1ef32ec22e764197f70cda73d82778d61e7a2", [:rebar3], [], "hexpm", "7015fc8919dbe63764f4b4b87a95b7c0996bd539e0d499be6ec9d7f3875b79e6"}, 15 | "tesla": {:hex, :tesla, "1.4.4", "bb89aa0c9745190930366f6a2ac612cdf2d0e4d7fff449861baa7875afd797b2", [:mix], [{:castore, "~> 0.1", [hex: :castore, repo: "hexpm", optional: true]}, {:exjsx, ">= 3.0.0", [hex: :exjsx, repo: "hexpm", optional: true]}, {:finch, "~> 0.3", [hex: :finch, repo: "hexpm", optional: true]}, {:fuse, "~> 2.4", [hex: :fuse, repo: "hexpm", optional: true]}, {:gun, "~> 1.3", [hex: :gun, repo: "hexpm", optional: true]}, {:hackney, "~> 1.6", [hex: :hackney, repo: "hexpm", optional: true]}, {:ibrowse, "4.4.0", [hex: :ibrowse, repo: "hexpm", optional: true]}, {:jason, ">= 1.0.0", [hex: :jason, repo: "hexpm", optional: true]}, {:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.0", [hex: :mint, repo: "hexpm", optional: true]}, {:poison, ">= 1.0.0", [hex: :poison, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: true]}], "hexpm", "d5503a49f9dec1b287567ea8712d085947e247cb11b06bc54adb05bfde466457"}, 16 | } 17 | -------------------------------------------------------------------------------- /test/tesla_http_cache_test.exs: -------------------------------------------------------------------------------- 1 | defmodule TeslaHTTPCacheTest do 2 | use ExUnit.Case 3 | 4 | @http_cache_opts %{ 5 | type: :private, 6 | store: :http_cache_store_process, 7 | stale_while_revalidate_supported: true 8 | } 9 | @test_url "http://no-exist-domain-adsxikfgjs.com" 10 | @test_req {"GET", @test_url, [], ""} 11 | @test_resp {200, [], "Some content"} 12 | 13 | defmodule EchoAdapter do 14 | @behaviour Tesla.Adapter 15 | 16 | @impl true 17 | def call(env, _opts) do 18 | send(env.opts[:caller], {:adapter_called, __MODULE__}) 19 | {:ok, env} 20 | end 21 | end 22 | 23 | defmodule UnreachableHostAdapter do 24 | @behaviour Tesla.Adapter 25 | 26 | @impl true 27 | def call(_env, _opts) do 28 | {:error, :econnrefused} 29 | end 30 | end 31 | 32 | defmodule OriginErrorAdapter do 33 | @behaviour Tesla.Adapter 34 | 35 | @impl true 36 | def call(env, http_status) do 37 | {:ok, %Tesla.Env{env | status: http_status}} 38 | end 39 | end 40 | 41 | setup do 42 | client = 43 | Tesla.client( 44 | [ 45 | {Tesla.Middleware.Opts, [caller: self()]}, 46 | {TeslaHTTPCache, %{store: :http_cache_store_process}} 47 | ], 48 | __MODULE__.EchoAdapter 49 | ) 50 | 51 | {:ok, client: client} 52 | end 53 | 54 | test "returns cached response", %{client: client} do 55 | ref = :telemetry_test.attach_event_handlers(self(), [[:tesla_http_cache, :hit]]) 56 | 57 | {:ok, _} = :http_cache.cache(@test_req, @test_resp, @http_cache_opts) 58 | 59 | {:ok, env} = Tesla.get(client, @test_url) 60 | 61 | assert env.status == 200 62 | assert env.body == "Some content" 63 | assert List.keymember?(env.headers, "age", 0) 64 | assert_received {[:tesla_http_cache, :hit], ^ref, _, %{freshness: :fresh, env: %Tesla.Env{}}} 65 | end 66 | 67 | test "handles query parameters", %{client: client} do 68 | {:ok, _} = 69 | :http_cache.cache( 70 | {"GET", Tesla.build_url(@test_url, param: "value"), [], ""}, 71 | @test_resp, 72 | @http_cache_opts 73 | ) 74 | 75 | {:ok, env} = Tesla.get(client, @test_url, query: [param: "value"]) 76 | assert List.keymember?(env.headers, "age", 0) 77 | 78 | {:ok, env} = Tesla.get(client, @test_url, query: [param: "another-value"]) 79 | refute List.keymember?(env.headers, "age", 0) 80 | end 81 | 82 | test "returns response stored in file", %{client: client} do 83 | :http_cache_store_process.save_in_file() 84 | {:ok, _} = :http_cache.cache(@test_req, @test_resp, @http_cache_opts) 85 | 86 | {:ok, env} = Tesla.get(client, @test_url) 87 | 88 | assert env.status == 200 89 | assert env.body == "Some content" 90 | assert List.keymember?(env.headers, "age", 0) 91 | end 92 | 93 | test "returns response stored in file with range", %{client: client} do 94 | :http_cache_store_process.save_in_file() 95 | {:ok, _} = :http_cache.cache(@test_req, @test_resp, @http_cache_opts) 96 | 97 | {:ok, env} = Tesla.get(client, @test_url, headers: [{"range", "bytes=0-3"}]) 98 | 99 | assert env.status == 206 100 | assert env.body == "Some" 101 | assert List.keymember?(env.headers, "age", 0) 102 | end 103 | 104 | test "returns cached response when cache is disconnected and stale-if-error configured" do 105 | ref = :telemetry_test.attach_event_handlers(self(), [[:tesla_http_cache, :hit]]) 106 | 107 | client = 108 | Tesla.client( 109 | [{TeslaHTTPCache, %{store: :http_cache_store_process}}], 110 | __MODULE__.UnreachableHostAdapter 111 | ) 112 | 113 | {:ok, _} = 114 | :http_cache.cache( 115 | @test_req, 116 | {200, [{"cache-control", "max-age=0, stale-if-error=3600"}], "Some content"}, 117 | @http_cache_opts 118 | ) 119 | 120 | {:ok, env} = Tesla.get(client, @test_url) 121 | 122 | assert env.status == 200 123 | assert env.body == "Some content" 124 | assert List.keymember?(env.headers, "age", 0) 125 | assert_received {[:tesla_http_cache, :hit], ^ref, _, %{freshness: :stale, env: %Tesla.Env{}}} 126 | end 127 | 128 | for http_status <- [500, 502, 503, 504] do 129 | test "returns cached response when origin returns a #{http_status} error" do 130 | ref = :telemetry_test.attach_event_handlers(self(), [[:tesla_http_cache, :hit]]) 131 | 132 | client = 133 | Tesla.client( 134 | [{TeslaHTTPCache, %{store: :http_cache_store_process}}], 135 | {__MODULE__.OriginErrorAdapter, unquote(http_status)} 136 | ) 137 | 138 | {:ok, _} = 139 | :http_cache.cache( 140 | @test_req, 141 | {200, [{"cache-control", "max-age=0"}], "Some content"}, 142 | @http_cache_opts 143 | ) 144 | 145 | {:ok, env} = 146 | Tesla.get(client, @test_url, headers: [{"cache-control", "stale-if-error=3600"}]) 147 | 148 | assert env.status == 200 149 | assert env.body == "Some content" 150 | assert List.keymember?(env.headers, "age", 0) 151 | 152 | assert_received {[:tesla_http_cache, :hit], ^ref, _, 153 | %{freshness: :stale, env: %Tesla.Env{}}} 154 | end 155 | end 156 | 157 | test "raises when store option is missing" do 158 | client = Tesla.client([{TeslaHTTPCache, {}}]) 159 | 160 | assert_raise RuntimeError, fn -> Tesla.get(client, @test_url) end 161 | end 162 | 163 | test "raises if body is not a binary or an IOlist", %{client: client} do 164 | assert_raise TeslaHTTPCache.InvalidBodyError, fn -> 165 | Tesla.get(client, @test_url, body: %{"some" => "json"}) 166 | end 167 | end 168 | 169 | test "adds etag validator when validating response", %{client: client} do 170 | resp = {200, [{"etag", "some_etag"}, {"cache-control", "max-age=0"}], "Some content"} 171 | {:ok, _} = :http_cache.cache(@test_req, resp, @http_cache_opts) 172 | 173 | {:ok, env} = Tesla.get(client, @test_url) 174 | 175 | assert List.keymember?(env.headers, "if-none-match", 0) 176 | end 177 | 178 | test "adds last-modified validator when validating response", %{client: client} do 179 | resp = 180 | {200, [{"last-modified", "Wed, 21 Oct 2015 07:28:00 GMT"}, {"cache-control", "max-age=0"}], 181 | "Some content"} 182 | 183 | {:ok, _} = :http_cache.cache(@test_req, resp, @http_cache_opts) 184 | 185 | {:ok, env} = Tesla.get(client, @test_url) 186 | 187 | assert List.keymember?(env.headers, "if-modified-since", 0) 188 | end 189 | 190 | test "revalidates asynchronously with `stale-while-revalidate`", %{client: client} do 191 | {:ok, _} = 192 | :http_cache.cache( 193 | @test_req, 194 | {200, [{"cache-control", "max-age=0, stale-while-revalidate=60"}], "Some content"}, 195 | @http_cache_opts 196 | ) 197 | 198 | {:ok, env} = Tesla.get(client, @test_url) 199 | 200 | assert env.status == 200 201 | assert env.body == "Some content" 202 | assert List.keymember?(env.headers, "age", 0) 203 | 204 | assert_receive {:adapter_called, _} 205 | end 206 | end 207 | -------------------------------------------------------------------------------- /lib/tesla_http_cache.ex: -------------------------------------------------------------------------------- 1 | defmodule TeslaHTTPCache do 2 | @moduledoc """ 3 | Middleware implementation 4 | 5 | Refer to the README for more information. 6 | """ 7 | 8 | @behaviour Tesla.Middleware 9 | 10 | @default_opts %{ 11 | auto_accept_encoding: true, 12 | auto_compress: true, 13 | type: :private, 14 | stale_while_revalidate_supported: true 15 | } 16 | 17 | defmodule InvalidBodyError do 18 | @moduledoc """ 19 | Error raised when a downstream middleware returns a non-binary and non-IOlist response body 20 | """ 21 | 22 | defexception [:message] 23 | 24 | @impl true 25 | def message(_), 26 | do: """ 27 | TeslaHTTPCache received invalid body. Body must be `iodata()` or `nil` 28 | 29 | For requests, body must be encoded before calling this middleware (for 30 | example encoded to JSON) and for responses, the body must be decoded 31 | after (for instance, decoded to Elixir from a JSON string). 32 | """ 33 | end 34 | 35 | @impl true 36 | def call(env, next, opts) do 37 | opts = init_opts(opts) 38 | 39 | do_call(env, next, opts) 40 | end 41 | 42 | defp do_call(env, next, opts) do 43 | request = to_http_cache_request(env) 44 | 45 | case :http_cache.get(request, opts) do 46 | {:fresh, _} = response -> 47 | return_cached_response(response, env, opts) 48 | 49 | {:stale, _} = response -> 50 | if revalidate_stale_response?(response, opts) do 51 | Task.start(fn -> revalidate(request, response, env, next, opts) end) 52 | end 53 | 54 | return_cached_response(response, env, opts) 55 | 56 | {:must_revalidate, _} = response -> 57 | :http_cache.notify_downloading(request, self(), opts) 58 | 59 | revalidate(request, response, env, next, opts) 60 | 61 | :miss -> 62 | :telemetry.execute([:tesla_http_cache, :miss], %{env: env}) 63 | 64 | :http_cache.notify_downloading(request, self(), opts) 65 | 66 | opts = Map.put(opts, :request_time, now()) 67 | 68 | env 69 | |> Tesla.run(next) 70 | |> handle_response(request, env, opts) 71 | end 72 | rescue 73 | e in InvalidBodyError -> 74 | raise e 75 | 76 | _ -> 77 | Tesla.run(env, next) 78 | end 79 | 80 | defp revalidate( 81 | request, 82 | {_, {_, {_, cached_headers, _} = revalidated_response}}, 83 | env, 84 | next, 85 | opts 86 | ) do 87 | opts = Map.put(opts, :request_time, now()) 88 | 89 | env 90 | |> add_validator(cached_headers, "last-modified", "if-modified-since") 91 | |> add_validator(cached_headers, "etag", "if-none-match") 92 | |> Tesla.run(next) 93 | |> handle_response(request, revalidated_response, env, opts) 94 | end 95 | 96 | defp handle_response(result, http_cache_req, http_cache_revalidated_resp \\ nil, req_env, opts) 97 | 98 | defp handle_response( 99 | {:ok, %Tesla.Env{status: 304} = resp_env}, 100 | http_cache_req, 101 | http_cache_revalidated_resp, 102 | _req_env, 103 | opts 104 | ) do 105 | :telemetry.execute([:tesla_http_cache, :hit], %{}, %{freshness: :revalidated, env: resp_env}) 106 | 107 | case :http_cache.cache( 108 | http_cache_req, 109 | to_http_cache_response(resp_env), 110 | http_cache_revalidated_resp, 111 | opts 112 | ) do 113 | {:ok, http_cache_resp} -> 114 | {:ok, to_tesla_response(resp_env, http_cache_resp)} 115 | 116 | :not_cacheable -> 117 | {:ok, resp_env} 118 | end 119 | end 120 | 121 | defp handle_response( 122 | {:ok, %Tesla.Env{} = resp_env}, 123 | http_cache_req, 124 | _http_cache_revalidated_resp, 125 | _req_env, 126 | opts 127 | ) do 128 | case :http_cache.cache(http_cache_req, to_http_cache_response(resp_env), opts) do 129 | {:ok, http_cache_resp} -> 130 | {:ok, to_tesla_response(resp_env, http_cache_resp)} 131 | 132 | {:not_cacheable, {response_ref, response}} -> 133 | return_cached_response({:stale, {response_ref, response}}, resp_env, opts) 134 | 135 | :not_cacheable -> 136 | {:ok, resp_env} 137 | end 138 | end 139 | 140 | defp handle_response( 141 | {:error, _reason} = error, 142 | http_cache_req, 143 | _http_cache_revalidated_resp, 144 | req_env, 145 | opts 146 | ) do 147 | case :http_cache.cache(http_cache_req, {502, [], ""}, opts) do 148 | # stale-if-error case 149 | {:not_cacheable, {response_ref, response}} -> 150 | return_cached_response({:stale, {response_ref, response}}, req_env, opts) 151 | 152 | :not_cacheable -> 153 | error 154 | end 155 | end 156 | 157 | defp return_cached_response({freshness, {response_ref, response}}, env, opts) do 158 | :http_cache.notify_response_used(response_ref, opts) 159 | :telemetry.execute([:tesla_http_cache, :hit], %{}, %{freshness: freshness, env: env}) 160 | 161 | {:ok, to_tesla_response(env, response)} 162 | end 163 | 164 | defp to_http_cache_request(env) do 165 | { 166 | env.method |> to_string() |> String.upcase(), 167 | Tesla.build_url(env.url, env.query), 168 | env.headers, 169 | (env.body || "") |> :erlang.iolist_to_binary() 170 | } 171 | rescue 172 | _ -> 173 | raise %__MODULE__.InvalidBodyError{} 174 | end 175 | 176 | defp to_http_cache_response(env) do 177 | { 178 | env.status, 179 | env.headers, 180 | (env.body || "") |> :erlang.iolist_to_binary() 181 | } 182 | rescue 183 | _ -> 184 | raise %__MODULE__.InvalidBodyError{} 185 | end 186 | 187 | defp to_tesla_response(env, {status, resp_headers, {:sendfile, offset, :all, path}}) do 188 | file_size = File.stat!(path).size 189 | 190 | to_tesla_response(env, {status, resp_headers, {:sendfile, offset, file_size, path}}) 191 | end 192 | 193 | defp to_tesla_response(env, {status, resp_headers, {:sendfile, offset, length, path}}) do 194 | file = File.open!(path, [:read, :raw, :binary]) 195 | 196 | try do 197 | {:ok, content} = :file.pread(file, offset, length) 198 | %Tesla.Env{env | status: status, headers: resp_headers, body: content} 199 | after 200 | File.close(file) 201 | end 202 | end 203 | 204 | defp to_tesla_response(env, {status, resp_headers, body}) do 205 | %Tesla.Env{env | status: status, headers: resp_headers, body: :erlang.iolist_to_binary(body)} 206 | end 207 | 208 | defp add_validator(env, cached_headers, validator, condition_header) do 209 | cached_headers 210 | |> Enum.find(fn {header_name, _} -> String.downcase(header_name) == validator end) 211 | |> case do 212 | {_, header_value} -> 213 | Tesla.put_header(env, condition_header, header_value) 214 | 215 | nil -> 216 | env 217 | end 218 | end 219 | 220 | defp revalidate_stale_response?(response, opts) do 221 | {:stale, {_resp_ref, {_status, headers, _body}}} = response 222 | 223 | # In theory we could erroneously revalidate a response with an expired timeout in 224 | # `stale-while-revalidate` if the `max-stale` is used as well and as a greater duration. 225 | # In practice this is deemed good enough™ for now 226 | 227 | opts[:stale_while_revalidate_supported] == true and 228 | Enum.any?(headers, fn {name, value} -> 229 | String.downcase(name) == "cache-control" and 230 | String.contains?(value, "stale-while-revalidate=") 231 | end) 232 | end 233 | 234 | defp init_opts(%{store: _} = opts) do 235 | Map.merge(@default_opts, opts) 236 | end 237 | 238 | defp init_opts(_) do 239 | raise("Missing `store` http_cache option") 240 | end 241 | 242 | defp now(), do: :os.system_time(:second) 243 | end 244 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # TeslaHTTPCache 2 | 3 | HTTP caching middleware for Tesla 4 | 5 | ## Installation 6 | 7 | ```elixir 8 | def deps do 9 | [ 10 | {:http_cache, "~> 0.4.0"}, 11 | {:tesla_http_cache, "~> 0.4.0"} 12 | ] 13 | end 14 | ``` 15 | 16 | Responses have to be stored in a separate store backend (this library does not come with one), such 17 | as: 18 | - [`http_cache_store_memory`](https://github.com/tanguilp/http_cache_store_memory): responses are 19 | stored in memory (ETS) 20 | - [`http_cache_store_disk`](https://github.com/tanguilp/http_cache_store_disk): responses are 21 | stored on disk. An application using the `sendfile` system call (such as 22 | [`plug_http_cache`](https://github.com/tanguilp/plug_http_cache)) may benefit from the kernel's 23 | memory caching automatically 24 | 25 | Both are cluster-aware. 26 | 27 | ## Configuration 28 | 29 | Options of this middleware are options of the [http_cache](https://hexdocs.pm/http_cache) 30 | library, By default, the following options are set: 31 | - `:type`: `:private` 32 | - `:auto_accept_encoding`: `true` 33 | - `:auto_compress`: `true` 34 | 35 | The `:store` option must be set when configuring the middleware. 36 | 37 | ## Examples 38 | 39 | Notice the `age` response header after the first request. 40 | 41 | ```elixir 42 | iex> client = Tesla.client([{TeslaHTTPCache, %{store: :http_cache_store_process}}]) 43 | %Tesla.Client{ 44 | fun: nil, 45 | pre: [{TeslaHTTPCache, :call, [%{store: :http_cache_store_process}]}], 46 | post: [], 47 | adapter: nil 48 | } 49 | iex> Tesla.get!(client, "http://perdu.com") 50 | %Tesla.Env{ 51 | method: :get, 52 | url: "http://perdu.com", 53 | query: [], 54 | headers: [ 55 | {"cache-control", "max-age=600"}, 56 | {"date", "Sat, 22 Apr 2023 14:15:11 GMT"}, 57 | {"etag", "W/\"cc-5344555136fe9-gzip\""}, 58 | {"server", "cloudflare"}, 59 | {"vary", "Accept-Encoding,User-Agent"}, 60 | {"content-type", "text/html"}, 61 | {"expires", "Sat, 22 Apr 2023 14:25:11 GMT"}, 62 | {"last-modified", "Thu, 02 Jun 2016 06:01:08 GMT"}, 63 | {"cf-cache-status", "DYNAMIC"}, 64 | {"report-to", 65 | "{\"endpoints\":[{\"url\":\"https:\\/\\/a.nel.cloudflare.com\\/report\\/v3?s=OW%2BJhOzTmxq4FGquM7w7bvkDLoryGQY9elB6ajNGx6Wgw0%2BjJechCF9vurIyh1V8rJ%2F0O6KL%2B36xUILE8SICSy1o0O1%2FrR2lx0XHgsN0ZWhBXsWf81OnlHM6ITw%3D\"}],\"group\":\"cf-nel\",\"max_age\":604800}"}, 66 | {"nel", 67 | "{\"success_fraction\":0,\"report_to\":\"cf-nel\",\"max_age\":604800}"}, 68 | {"cf-ray", "7bbe7a35ea419bc2-FRA"}, 69 | {"alt-svc", "h3=\":443\"; ma=86400, h3-29=\":443\"; ma=86400"}, 70 | {"content-length", "204"} 71 | ], 72 | body: "
* <----- vous êtes ici\n", 73 | status: 200, 74 | opts: [], 75 | __module__: Tesla, 76 | __client__: %Tesla.Client{ 77 | fun: nil, 78 | pre: [{TeslaHTTPCache, :call, [%{store: :http_cache_store_process}]}], 79 | post: [], 80 | adapter: nil 81 | } 82 | } 83 | iex> Tesla.get!(client, "http://perdu.com") 84 | %Tesla.Env{ 85 | method: :get, 86 | url: "http://perdu.com", 87 | query: [], 88 | headers: [ 89 | {"cache-control", "max-age=600"}, 90 | {"date", "Sat, 22 Apr 2023 14:15:11 GMT"}, 91 | {"etag", "W/\"cc-5344555136fe9-gzip\""}, 92 | {"server", "cloudflare"}, 93 | {"vary", "Accept-Encoding,User-Agent"}, 94 | {"content-type", "text/html"}, 95 | {"expires", "Sat, 22 Apr 2023 14:25:11 GMT"}, 96 | {"last-modified", "Thu, 02 Jun 2016 06:01:08 GMT"}, 97 | {"cf-cache-status", "DYNAMIC"}, 98 | {"report-to", 99 | "{\"endpoints\":[{\"url\":\"https:\\/\\/a.nel.cloudflare.com\\/report\\/v3?s=OW%2BJhOzTmxq4FGquM7w7bvkDLoryGQY9elB6ajNGx6Wgw0%2BjJechCF9vurIyh1V8rJ%2F0O6KL%2B36xUILE8SICSy1o0O1%2FrR2lx0XHgsN0ZWhBXsWf81OnlHM6ITw%3D\"}],\"group\":\"cf-nel\",\"max_age\":604800}"}, 100 | {"nel", 101 | "{\"success_fraction\":0,\"report_to\":\"cf-nel\",\"max_age\":604800}"}, 102 | {"cf-ray", "7bbe7a35ea419bc2-FRA"}, 103 | {"alt-svc", "h3=\":443\"; ma=86400, h3-29=\":443\"; ma=86400"}, 104 | {"content-length", "204"}, 105 | {"age", "8"} 106 | ], 107 | body: "
* <----- vous êtes ici\n", 108 | status: 200, 109 | opts: [], 110 | __module__: Tesla, 111 | __client__: %Tesla.Client{ 112 | fun: nil, 113 | pre: [{TeslaHTTPCache, :call, [%{store: :http_cache_store_process}]}], 114 | post: [], 115 | adapter: nil 116 | } 117 | } 118 | iex> Tesla.get!(client, "http://perdu.com", headers: [{"range", "bytes=12-43"}]) 119 | %Tesla.Env{ 120 | method: :get, 121 | url: "http://perdu.com", 122 | query: [], 123 | headers: [ 124 | {"cache-control", "max-age=600"}, 125 | {"date", "Sat, 22 Apr 2023 14:15:11 GMT"}, 126 | {"etag", "W/\"cc-5344555136fe9-gzip\""}, 127 | {"server", "cloudflare"}, 128 | {"vary", "Accept-Encoding,User-Agent"}, 129 | {"content-type", "text/html"}, 130 | {"expires", "Sat, 22 Apr 2023 14:25:11 GMT"}, 131 | {"last-modified", "Thu, 02 Jun 2016 06:01:08 GMT"}, 132 | {"cf-cache-status", "DYNAMIC"}, 133 | {"report-to", 134 | "{\"endpoints\":[{\"url\":\"https:\\/\\/a.nel.cloudflare.com\\/report\\/v3?s=OW%2BJhOzTmxq4FGquM7w7bvkDLoryGQY9elB6ajNGx6Wgw0%2BjJechCF9vurIyh1V8rJ%2F0O6KL%2B36xUILE8SICSy1o0O1%2FrR2lx0XHgsN0ZWhBXsWf81OnlHM6ITw%3D\"}],\"group\":\"cf-nel\",\"max_age\":604800}"}, 135 | {"nel", 136 | "{\"success_fraction\":0,\"report_to\":\"cf-nel\",\"max_age\":604800}"}, 137 | {"cf-ray", "7bbe7a35ea419bc2-FRA"}, 138 | {"alt-svc", "h3=\":443\"; ma=86400, h3-29=\":443\"; ma=86400"}, 139 | {"content-range", "bytes 12-43/204"}, 140 | {"content-length", "32"}, 141 | {"age", "22"} 142 | ], 143 | body: "
* <----- vous êtes ici\n", 178 | status: 200, 179 | opts: [], 180 | __module__: Tesla, 181 | __client__: %Tesla.Client{ 182 | fun: nil, 183 | pre: [{TeslaHTTPCache, :call, [%{store: :http_cache_store_process}]}], 184 | post: [], 185 | adapter: nil 186 | } 187 | } 188 | 189 | ``` 190 | 191 | ## Middleware order 192 | 193 | `http_cache` only caches iodata bodies. As a consequence, beware of not decoding the body before 194 | caching. For instance, do not: 195 | 196 | ```elixir 197 | defp client(token) do 198 | Tesla.client([ 199 | Tesla.Middleware.Logger, 200 | {Tesla.Middleware.BearerAuth, token: token}, 201 | {TeslaHTTPCache, %{store: :http_cache_store_memory}}, 202 | Tesla.Middleware.JSON # <- WRONG!!! 203 | ]) 204 | end 205 | ``` 206 | 207 | as the JSON will be decoded before caching, and the body wil be a map (not cacheable). Instead do: 208 | 209 | ```elixir 210 | defp client(token) do 211 | Tesla.client([ 212 | Tesla.Middleware.Logger, 213 | {Tesla.Middleware.BearerAuth, token: token}, 214 | Tesla.Middleware.JSON, 215 | {TeslaHTTPCache, %{store: :http_cache_store_memory}} 216 | ]) 217 | end 218 | ``` 219 | 220 | Response will be cached first then decoded. 221 | 222 | ## Error handling 223 | 224 | Whenever an error occurs when requesting a URL, `TeslaHTTPCache` tries to find and return a response 225 | observing the `stale-if-error` cache control directive. 226 | 227 | This occurs when: 228 | - the result HTTP code is one of `500`, `502`, `503` or `504` (as per 229 | [RFC5861 - HTTP Cache-Control Extensions for Stale Content](https://datatracker.ietf.org/doc/html/rfc5861#section-4)) 230 | - the result is an error, for instance `{:error, :econnrefused}` 231 | 232 | If the origin doesn't set this header and you want to always fallback to a cached response in case 233 | of origin error or unreachability, you need to either: 234 | - manually set the `stale-if-error` *response* directive before caching (writing a custom 235 | middleware), or 236 | - add the `stale-if-error` *request* directive to your requests: 237 | 238 | ```elixir 239 | defp client(token) do 240 | Tesla.client([ 241 | Tesla.Middleware.Logger, 242 | {Tesla.Middleware.BearerAuth, token: token}, 243 | Tesla.Middleware.JSON, 244 | {Tesla.Middleware.Headers, [{"cache-control", "stale-if-error=3600"}]}, 245 | {TeslaHTTPCache, %{store: :http_cache_store_memory}} 246 | ]) 247 | end 248 | ``` 249 | 250 | Note that when using the *request* `stale-if-error` directive, the cache has no clue how long to 251 | store the response after it expires (in *stale* state) and might delete it before you expected. 252 | See `http_cache` [`default_grace` option](https://hexdocs.pm/http_cache/http_cache.html#t:opts/0) to 253 | fine tune this behaviour. 254 | 255 | ## Telemetry events 256 | 257 | The following events are emitted: 258 | - `[:tesla_http_cache, :hit]` when a response is returned from the cache 259 | - measurements: none 260 | - metadata: 261 | - `:env`: the `%Tesla.Env{}` associated with the event 262 | - `:freshness`: one of 263 | - `:fresh`: a fresh response was returned 264 | - `:stale`: a stale response was returned 265 | - `:revalidated`: the response was successfully revalidated and returned 266 | - `[:tesla_http_cache, :miss]` in case of cache miss 267 | - measurements: none 268 | - metadata: 269 | - `:env`: the `%Tesla.Env{}` associated with the event 270 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | --------------------------------------------------------------------------------