├── .formatter.exs ├── .gitignore ├── CHANGELOG.md ├── LICENSE ├── README.md ├── lib ├── plug_http_cache.ex └── plug_http_cache │ └── stale_if_error.ex ├── media └── grafana.png ├── mix.exs ├── mix.lock └── test ├── plug_http_cache_test.exs ├── plug_http_cache_test └── stale_if_error_test.exs └── test_helper.exs /.formatter.exs: -------------------------------------------------------------------------------- 1 | # Used by "mix format" 2 | [ 3 | inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"] 4 | ] 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # The directory Mix will write compiled artifacts to. 2 | /_build/ 3 | 4 | # If you run "mix test --cover", coverage assets end up here. 5 | /cover/ 6 | 7 | # The directory Mix downloads your dependencies sources to. 8 | /deps/ 9 | 10 | # Where third-party dependencies like ExDoc output generated docs. 11 | /doc/ 12 | 13 | # Ignore .fetch files in case you like to edit your project deps locally. 14 | /.fetch 15 | 16 | # If the VM crashes, it generates a dump, let's ignore it too. 17 | erl_crash.dump 18 | 19 | # Also ignore archive artifacts (built via "mix archive.build"). 20 | *.ez 21 | 22 | # Ignore package tarball (built via "mix hex.build"). 23 | plug_http_cache-*.tar 24 | 25 | -------------------------------------------------------------------------------- /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 | - [`PlugHTTPCache`] The `stale-while-revalidate` cache-control directive is now supported 12 | - [`PlugHTTPCache`] The conn was added to telemetry events' metadata 13 | 14 | ### Changed 15 | 16 | - [`PlugHTTPCache`] Elixir 1.18+ is required 17 | - [`PlugHTTPCache.StaleIfError`] `stale-if-error` is now supported only through the use 18 | of this cache-control directive. See the update module's documentation 19 | 20 | ## [0.3.1] - 2023-09-10 21 | 22 | ### Added 23 | 24 | - [`PlugHTTPCache`] The `conn` is added to telemetry events' metadata 25 | 26 | ### Changed 27 | 28 | - [`PlugHTTPCache`] When the request body is parsed and there are no parameters, it 29 | is set to the empty string `""` 30 | 31 | ## [0.3.0] - 2023-06-22 32 | 33 | ### Changed 34 | 35 | - [`PlugHTTPCache`] Make `http_cache` an optional depedency 36 | 37 | ## [0.2.0] - 2023-04-25 38 | 39 | ### Changed 40 | 41 | - [`PlugHTTPCache`] Update to use `http_cache` `0.2.0` 42 | - [`PlugHTTPCache`] Options are now a map (was previously a keyword list) 43 | 44 | ## [0.1.0] - 2022-08-21 45 | 46 | Initial release 47 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # PlugHTTPCache 2 | 3 | A Plug that caches HTTP responses 4 | 5 | This plug library relies on the [`http_cache`](https://hexdocs.pm/http_cache) 6 | library. It supports all caching features of 7 | [RFC9111](https://datatracker.ietf.org/doc/html/rfc9111) and more 8 | (such as conditional requests and range requests). 9 | 10 | See [`http_cache`](https://hexdocs.pm/http_cache/) documentation for more information. 11 | 12 | ![Screenshot of pug_http_cache_demo Grafana dashboard](https://raw.githubusercontent.com/tanguilp/plug_http_cache/master/media/grafana.png) 13 | 14 | Screenshot from the [`plug_http_cache_demo`](https://github.com/tanguilp/plug_http_cache_demo) 15 | application. 16 | 17 | ## Installation 18 | 19 | ```elixir 20 | def deps do 21 | [ 22 | {:http_cache, "~> 0.4.0"}, 23 | {:plug_http_cache, "~> 0.4.0"} 24 | ] 25 | end 26 | ``` 27 | 28 | ## Configuration 29 | 30 | In your plug pipeline, set the Plug for routes on which you want to enable caching: 31 | 32 | `router.ex` 33 | 34 | ```elixir 35 | pipeline :cache do 36 | plug PlugHTTPCache, @caching_options 37 | end 38 | 39 | ... 40 | 41 | scope "/", PlugHTTPCacheDemoWeb do 42 | pipe_through :browser 43 | 44 | scope "/some_route" do 45 | pipe_through :cache 46 | 47 | ... 48 | end 49 | end 50 | ``` 51 | 52 | You can also configure it for all requests by setting it in Phoenix's endpoint 53 | file: 54 | 55 | `endpoint.ex` 56 | 57 | ```elixir 58 | defmodule MyApp.Endpoint do 59 | use Phoenix.Endpoint, otp_app: :plug_http_cache_demo 60 | 61 | % some other plugs 62 | 63 | plug Plug.RequestId 64 | plug Plug.Telemetry, event_prefix: [:phoenix, :endpoint] 65 | plug Plug.Parsers, 66 | parsers: [:urlencoded, :multipart, :json], 67 | pass: ["*/*"], 68 | json_decoder: Phoenix.json_library() 69 | plug Plug.Head 70 | plug Plug.Session, @session_options 71 | 72 | plug PlugHTTPCache, @caching_options 73 | 74 | plug PlugHTTPCacheDemoWeb.Router 75 | end 76 | ``` 77 | 78 | Note that: 79 | - caching chunked responses is *not* supported 80 | - some responses (called "cacheable by default") can be cached even when no 81 | `cache-control` header is set. For instance, a 200 response to a get request is 82 | cached 2 minutes by default, unless `cache-control` headers prohibit it 83 | - Phoenix automatically sets the `"cache-control"` header to 84 | `"max-age=0, private, must-revalidate"`, so by default no response will ever 85 | be cached unless you override this header 86 | 87 | You can also configure `PlugHTTPCache.StaleIfError` to return expired cached responses. 88 | This is useful to continue returning something when the backend experiences failures 89 | (for example if the DB crashed and while it's rebooting). 90 | 91 | ## Plug options 92 | 93 | Plug options are those documented by 94 | [`:http_cache.opts/0`](https://hexdocs.pm/http_cache/http_cache.html#t:opts/0). 95 | 96 | The only required option is `:store`. 97 | 98 | This plug sets the following default options: 99 | - `:type`: `:shared`, 100 | - `:auto_compress`: `true`, 101 | - `:auto_accept_encoding`: `true` 102 | - `:stale_while_revalidate_supported`: `true` 103 | 104 | ## Stores 105 | 106 | Responses have to be stored in a separate store backend (this library does not come with one), such 107 | as: 108 | - [`http_cache_store_memory`](https://github.com/tanguilp/http_cache_store_memory): responses are 109 | stored in memory (ETS) 110 | - [`http_cache_store_disk`](https://github.com/tanguilp/http_cache_store_disk): responses are 111 | stored on disk. This library uses the `sendfile` system call and therefore benefits from the kernel's 112 | memory caching automatically 113 | 114 | Both are cluster-aware. 115 | 116 | To use it along with this library, just add it to your mix.exs file: 117 | 118 | `mix.exs` 119 | 120 | ```elixir 121 | {:http_cache, "~> ..."}, 122 | {:plug_http_cache, "~> ..."}, 123 | {:http_cache_store_memory, "~> ..."}, 124 | ``` 125 | 126 | ## Security considerations 127 | 128 | Unlike many HTTP caches, `http_cache` allows caching: 129 | - responses to authorized request (with an `"authorization"` header) 130 | - responses with cookies 131 | 132 | In the first case, beware of authenticating before handling caching. In 133 | other words, **don't**: 134 | 135 | ```elixir 136 | PlugHTTPCache, @caching_options 137 | MyPlug.AuthorizeUser 138 | ``` 139 | 140 | which would return a cached response to unauthorized users, but **do** instead: 141 | 142 | ```elixir 143 | MyPlug.AuthorizeUser 144 | PlugHTTPCache, @caching_options 145 | ``` 146 | 147 | Beware of not setting caching headers on private responses containing cookies. 148 | 149 | ## Useful libraries 150 | 151 | - [`PlugCacheControl`](https://github.com/krasenyp/plug_cache_control) can be used 152 | to set cache-control headers in your Plug pipelines, or manually in your controllers 153 | - [`PlugHTTPValidator`](https://github.com/tanguilp/plug_http_validator) *should* be used 154 | to set HTTP validators as soon as cacheable content is returned. See project 155 | documentation to figure out why 156 | 157 | ## Telemetry events 158 | 159 | The following events are emitted: 160 | - `[:plug_http_cache, :hit]` when a cached response is returned. 161 | - `[:plug_http_cache, :miss]` when no cached response was found 162 | - `[:plug_http_cache, :stale_if_error]` when a response was returned because an error 163 | occurred downstream (see `PlugHTTPCache.StaleIfError`) 164 | 165 | `conn` is added to the events' metadata. 166 | 167 | The `http_cache`, `http_cache_store_memory` and `http_cache_store_disk` emit other events about 168 | the caching subsystems, including some helping with detecting normalization issues. 169 | 170 | ## Normalization 171 | 172 | The underlying http caching library may store different responses for the same URL, 173 | following the directives of the `"vary"` header. For instance, if a response can 174 | be returned in English or in French, both versions can be cached as long as the 175 | `"vary"` header is correctly used. 176 | 177 | This can unfortunately result in an explosion of stored responses if the headers 178 | are not normalized. For instance, in this scenario where a site handles both these 179 | languages, a response will be stored for any of these requests that include an 180 | `"accept-language"` header: 181 | - fr-CH, fr;q=0.9, en;q=0.8, de;q=0.7, *;q=0.5 182 | - fr-CH, fr;q=0.9, en;q=0.8, de;q=0.7,*;q=0.5 183 | - en 184 | - de 185 | - en, de 186 | - en, de, fr 187 | - en;q=1, de 188 | - en;q=1, de;q=0.9 189 | - en;q=1, de;q=0.8 190 | - en;q=1, de;q=0.7 191 | - en;q=1, de;q=0.6 192 | - en;q=1, de;q=0.5 193 | 194 | and so on, so potentially hundreds of stored responses for only 2 available 195 | responses (English and French versions). 196 | 197 | In this case, you probably want to apply normalization before caching. This 198 | could be done by a plug set before the `PlugHTTPCache` plug. 199 | 200 | See [Best practices for using the Vary header](https://www.fastly.com/blog/best-practices-using-vary-header) 201 | for more guidance regarding this issue. 202 | -------------------------------------------------------------------------------- /lib/plug_http_cache.ex: -------------------------------------------------------------------------------- 1 | defmodule PlugHTTPCache do 2 | @moduledoc """ 3 | A Plug that caches HTTP responses 4 | 5 | This plug library relies on the `http_cache` library. It supports all caching 6 | features of [RFC9111](https://datatracker.ietf.org/doc/html/rfc9111) and more 7 | (such as conditional requests and range requests). 8 | 9 | See [`http_cache`](https://hexdocs.pm/http_cache/) documentation for more information. 10 | 11 | ## Configuration 12 | 13 | In your plug pipeline, set the Plug for routes on which you want to enable caching: 14 | 15 | `router.ex` 16 | 17 | pipeline :cache do 18 | plug PlugHTTPCache, @caching_options 19 | end 20 | 21 | ... 22 | 23 | scope "/", PlugHTTPCacheDemoWeb do 24 | pipe_through :browser 25 | 26 | scope "/some_route" do 27 | pipe_through :cache 28 | 29 | ... 30 | end 31 | end 32 | 33 | You can also configure it for all requests by setting it in Phoenix's endpoint 34 | file: 35 | 36 | `endpoint.ex` 37 | 38 | defmodule MyApp.Endpoint do 39 | use Phoenix.Endpoint, otp_app: :plug_http_cache_demo 40 | 41 | % some other plugs 42 | 43 | plug Plug.RequestId 44 | plug Plug.Telemetry, event_prefix: [:phoenix, :endpoint] 45 | plug Plug.Parsers, 46 | parsers: [:urlencoded, :multipart, :json], 47 | pass: ["*/*"], 48 | json_decoder: Phoenix.json_library() 49 | plug Plug.Head 50 | plug Plug.Session, @session_options 51 | 52 | plug PlugHTTPCache, @caching_options 53 | 54 | plug PlugHTTPCacheDemoWeb.Router 55 | end 56 | 57 | Note that: 58 | - caching chunked responses is *not* supported 59 | - some responses (called "cacheable by default") can be cached even when no 60 | `cache-control` header is set. For instance, a 200 response to a get request is 61 | cached 2 minutes by default, unless `cache-control` headers prohibit it 62 | - Phoenix automatically sets the `"cache-control"` header to 63 | `"max-age=0, private, must-revalidate"`, so by default no response will ever 64 | be cached unless you override this header 65 | 66 | You can also configure `PlugHTTPCache.StaleIfError` to return expired cached responses. 67 | This is useful to continue returning something when the backend experiences failures 68 | (for example if the DB crashed and while it's rebooting). 69 | 70 | ## Plug options 71 | 72 | Plug options are those documented by 73 | [`:http_cache.opts/0`](https://hexdocs.pm/http_cache/http_cache.html#t:opts/0). 74 | 75 | The only required option is `:store`. 76 | 77 | This plug sets the following default options: 78 | - `:type`: `:shared`, 79 | - `:auto_compress`: `true`, 80 | - `:auto_accept_encoding`: `true` 81 | - `:stale_while_revalidate_supported`: `true` 82 | 83 | ## Stores 84 | 85 | Responses have to be stored in a separate store backend (this library does not come with one), such 86 | as: 87 | - [`http_cache_store_memory`](https://github.com/tanguilp/http_cache_store_memory): responses are 88 | stored in memory (ETS) 89 | - [`http_cache_store_disk`](https://github.com/tanguilp/http_cache_store_disk): responses are 90 | stored on disk. An application using the `sendfile` system call (such as 91 | [`plug_http_cache`](https://github.com/tanguilp/plug_http_cache)) may benefit from the kernel's 92 | memory caching automatically 93 | 94 | Both are cluster-aware. 95 | 96 | To use it along with this library, just add it to your mix.exs file: 97 | 98 | `mix.exs` 99 | 100 | ```elixir 101 | {:plug_http_cache, "~> ..."}, 102 | {:http_cache_store_memory, "~> ..."}, 103 | ``` 104 | 105 | ## Security considerations 106 | 107 | Unlike many HTTP caches, `http_cache` allows caching: 108 | - responses to authorized request (with an `"authorization"` header) 109 | - responses with cookies 110 | 111 | In the first case, beware of authenticating before handling caching. In 112 | other words, **don't**: 113 | 114 | PlugHTTPCache, @caching_options 115 | MyPlug.AuthorizeUser 116 | 117 | which would return a cached response to unauthorized users, but **do** instead: 118 | 119 | MyPlug.AuthorizeUser 120 | PlugHTTPCache, @caching_options 121 | 122 | Beware of not setting caching headers on private responses containing cookies. 123 | 124 | ## Useful libraries 125 | 126 | - [`PlugCacheControl`](https://github.com/krasenyp/plug_cache_control) can be used 127 | to set cache-control headers in your Plug pipelines, or manually in your controllers 128 | - [`PlugHTTPValidator`](https://github.com/tanguilp/plug_http_validator) *should* be used 129 | to set HTTP validators as soon as cacheable content is returned. See project 130 | documentation to figure out why 131 | 132 | ## Telemetry events 133 | 134 | The following events are emitted: 135 | - `[:plug_http_cache, :hit]` when a cached response is returned. 136 | - `[:plug_http_cache, :miss]` when no cached response was found 137 | - `[:plug_http_cache, :stale_if_error]` when a response was returned because an error 138 | occurred downstream (see `PlugHTTPCache.StaleIfError`) 139 | 140 | `conn` is added to the events' metadata. 141 | 142 | The `http_cache`, `http_cache_store_memory` and `http_cache_store_disk` emit other events about 143 | the caching subsystems, including some helping with detecting normalization issues. 144 | 145 | ## Normalization 146 | 147 | The underlying http caching library may store different responses for the same URL, 148 | following the directives of the `"vary"` header. For instance, if a response can 149 | be returned in English or in French, both versions can be cached as long as the 150 | `"vary"` header is correctly used. 151 | 152 | This can unfortunately result in an explosion of stored responses if the headers 153 | are not normalized. For instance, in this scenario where a site handles both these 154 | languages, a response will be stored for any of these requests that include an 155 | `"accept-language"` header: 156 | - fr-CH, fr;q=0.9, en;q=0.8, de;q=0.7, *;q=0.5 157 | - fr-CH, fr;q=0.9, en;q=0.8, de;q=0.7,*;q=0.5 158 | - en 159 | - de 160 | - en, de 161 | - en, de, fr 162 | - en;q=1, de 163 | - en;q=1, de;q=0.9 164 | - en;q=1, de;q=0.8 165 | - en;q=1, de;q=0.7 166 | - en;q=1, de;q=0.6 167 | - en;q=1, de;q=0.5 168 | 169 | and so on, so potentially hundreds of stored responses for only 2 available 170 | responses (English and French versions). 171 | 172 | In this case, you probably want to apply normalization before caching. This 173 | could be done by a plug set before the `PlugHTTPCache` plug. 174 | 175 | See [Best practices for using the Vary header](https://www.fastly.com/blog/best-practices-using-vary-header) 176 | for more guidance regarding this issue. 177 | 178 | """ 179 | 180 | @behaviour Plug 181 | 182 | @default_caching_options %{ 183 | type: :shared, 184 | auto_compress: true, 185 | auto_accept_encoding: true, 186 | stale_while_revalidate_supported: true 187 | } 188 | 189 | @doc """ 190 | Adds one or more alternate keys to the cached response 191 | 192 | Request with alternate keys can be later be invalidated with the 193 | `:http_cache.invalidate_by_alternate_key/2` function. 194 | """ 195 | @spec set_alternate_keys( 196 | Plug.Conn.t(), 197 | :http_cache.alternate_key() | [:http_cache.alternate_key()] 198 | ) :: Plug.Conn.t() 199 | def set_alternate_keys(conn, []) do 200 | conn 201 | end 202 | 203 | def set_alternate_keys( 204 | %Plug.Conn{private: %{plug_http_cache_alt_keys: existing_alt_keys}} = conn, 205 | alt_keys 206 | ) 207 | when is_list(existing_alt_keys) and is_list(alt_keys) do 208 | put_in(conn.private[:plug_http_cache_alt_keys], existing_alt_keys ++ alt_keys) 209 | end 210 | 211 | def set_alternate_keys(conn, alt_keys) when is_list(alt_keys) do 212 | put_in(conn.private[:plug_http_cache_alt_keys], []) 213 | |> set_alternate_keys(alt_keys) 214 | end 215 | 216 | def set_alternate_keys(conn, alt_keys) do 217 | set_alternate_keys(conn, [alt_keys]) 218 | end 219 | 220 | @impl true 221 | def init(opts) do 222 | Map.merge(@default_caching_options, opts) 223 | end 224 | 225 | @impl true 226 | def call(conn, opts) do 227 | http_cache_request = request(conn) 228 | 229 | case :http_cache.get(http_cache_request, opts) do 230 | {:fresh, {resp_ref, response}} -> 231 | telemetry_log(:hit, conn) 232 | notify_and_send_response(conn, resp_ref, response, opts) 233 | 234 | {:stale, {resp_ref, response}} -> 235 | if revalidate_stale_response?(response, opts), 236 | do: revalidate_stale_response(conn, response) 237 | 238 | telemetry_log(:hit, conn) 239 | notify_and_send_response(conn, resp_ref, response, opts) 240 | 241 | _ -> 242 | telemetry_log(:miss, conn) 243 | conn = install_callback(conn, opts) 244 | 245 | :http_cache.notify_downloading(http_cache_request, self(), opts) 246 | 247 | conn 248 | end 249 | end 250 | 251 | @doc false 252 | def notify_and_send_response(conn, resp_ref, response, opts) do 253 | :http_cache.notify_response_used(resp_ref, opts) 254 | 255 | send_response(conn, response, opts) 256 | end 257 | 258 | @doc false 259 | def send_response(conn, {status, resp_headers, {:sendfile, offset, length, path}}, opts) do 260 | %Plug.Conn{conn | resp_headers: resp_headers} 261 | |> Plug.Conn.send_file(status, path, offset, length) 262 | |> Plug.Conn.halt() 263 | rescue 264 | e -> 265 | case e do 266 | %File.Error{reason: :enoent} -> 267 | telemetry_log(:miss, conn) 268 | install_callback(conn, opts) 269 | 270 | _ -> 271 | reraise e, __STACKTRACE__ 272 | end 273 | end 274 | 275 | def send_response(conn, {status, resp_headers, iodata_body}, _opts) do 276 | %Plug.Conn{conn | resp_headers: resp_headers} 277 | |> Plug.Conn.send_resp(status, iodata_body) 278 | |> Plug.Conn.halt() 279 | end 280 | 281 | defp install_callback(conn, opts) do 282 | Plug.Conn.register_before_send(conn, &cache_response(&1, opts)) 283 | end 284 | 285 | defp cache_response(%Plug.Conn{state: :set} = conn, opts) do 286 | alt_keys = alt_keys(conn) 287 | http_cache_opts = Map.put(opts, :alternate_keys, alt_keys) 288 | 289 | # The response is already sent and we cannot modify it with the result of :http_cache.cache/3, 290 | # hence we don't use the result of this function 291 | :http_cache.cache(request(conn), response(conn), http_cache_opts) 292 | 293 | conn 294 | end 295 | 296 | defp cache_response(conn, _opts) do 297 | conn 298 | end 299 | 300 | defp alt_keys(%Plug.Conn{private: %{plug_http_cache_alt_keys: alt_keys}}), 301 | do: Enum.dedup(alt_keys) 302 | 303 | defp alt_keys(_), do: [] 304 | 305 | defp revalidate_stale_response(conn, cached_response) do 306 | {_, cached_headers, _} = cached_response 307 | 308 | Task.start(fn -> 309 | conn 310 | |> PlugLoopback.replay() 311 | |> Plug.Conn.update_req_header("cache-control", "max-stale=0", &(&1 <> ", max-stale=0")) 312 | |> add_validator(cached_headers, "last-modified", "if-modified-since") 313 | |> add_validator(cached_headers, "etag", "if-none-match") 314 | |> PlugLoopback.run() 315 | end) 316 | end 317 | 318 | defp add_validator(conn, cached_headers, validator, condition_header) do 319 | cached_headers 320 | |> Enum.find(fn {header_name, _} -> String.downcase(header_name) == validator end) 321 | |> case do 322 | {_, header_value} -> 323 | Plug.Conn.put_req_header(conn, condition_header, header_value) 324 | 325 | nil -> 326 | conn 327 | end 328 | end 329 | 330 | @doc false 331 | def request(conn) do 332 | { 333 | conn.method, 334 | Plug.Conn.request_url(conn), 335 | conn.req_headers, 336 | req_body(conn) 337 | } 338 | end 339 | 340 | defp response(conn) do 341 | { 342 | conn.status, 343 | conn.resp_headers, 344 | # We convert to binary before sending to another process to benefit from passing 345 | # a single reference to a binary versus possibly passing a IOlist to another 346 | # process, which would have to be copied 347 | :erlang.iolist_to_binary(conn.resp_body) 348 | } 349 | end 350 | 351 | defp revalidate_stale_response?(response, opts) do 352 | {_status, headers, _body} = response 353 | 354 | # In theory we could erroneously revalidate a response with an expired timeout in 355 | # `stale-while-revalidate` if the `max-stale` is used as well and as a greater duration. 356 | # In practice this is deemed good enough™ for now 357 | 358 | opts[:stale_while_revalidate_supported] == true and 359 | Enum.any?(headers, fn {name, value} -> 360 | String.downcase(name) == "cache-control" and 361 | String.contains?(value, "stale-while-revalidate=") 362 | end) 363 | end 364 | 365 | defp req_body(%Plug.Conn{body_params: %Plug.Conn.Unfetched{}}), do: "" 366 | defp req_body(%Plug.Conn{body_params: %{} = map}) when map_size(map) == 0, do: "" 367 | defp req_body(conn), do: :erlang.term_to_binary(conn.body_params) 368 | 369 | @doc false 370 | def telemetry_log(event, conn) do 371 | :telemetry.execute([:plug_http_cache, event], %{}, %{conn: conn}) 372 | end 373 | end 374 | -------------------------------------------------------------------------------- /lib/plug_http_cache/stale_if_error.ex: -------------------------------------------------------------------------------- 1 | defmodule PlugHTTPCache.StaleIfError do 2 | @moduledoc """ 3 | Return stale entries when backend fails 4 | 5 | This module can help returning stale responses when the backend is temporarily 6 | unusable (for instance when restarting the DB) or when some unexpected errors occur. 7 | One of this known errors by Elixir developers is the 8 | 9 | ** (DBConnection.ConnectionError) connection not available and request was dropped from queue after 2655ms. This means requests are coming in and your connection pool cannot serve them fast enough. You can address this by: 10 | 11 | 1. By tracking down slow queries and making sure they are running fast enough 12 | 2. Increasing the pool_size (albeit it increases resource consumption) 13 | 3. Allow requests to wait longer by increasing :queue_target and :queue_interval 14 | 15 | See DBConnection.start_link/2 for more information 16 | 17 | ecto errors which occur when it is under intense stress. 18 | 19 | Whenever a fresh response is found, it is returned by the `PlugHTTPCache` plug. Stale 20 | responses, however, aren't. Stale responses are those whose expiration has been reached 21 | but are still keep in the cache until the grace period is expired. 22 | 23 | By default, the `http_cache` library caches cacheable responses 2 minutes, and keep 24 | them 2 more minutes (which is called the *grace period*). By setting this module in your 25 | plug pipeline, a stale response can be returned whenever an exception is raised by the 26 | backend by adding this at the beginning of the router: 27 | 28 | use PlugHTTPCache.StaleIfError, ... % same options as when using `PlugHTTPCache` 29 | 30 | When using it jointly with `Plug.ErrorHandler`, you should add it before: 31 | 32 | use PlugHTTPCache.StaleIfError, ... % same options as when using `PlugHTTPCache` 33 | use Plug.ErrorHandler 34 | 35 | so that it is called before `Plug.ErrorHandler`'s generic error handling. In this case, 36 | if a stale response if found, it is returned. Otherwise, the error handler of 37 | `Plug.ErrorHandler` is called. 38 | 39 | Staled responses are returned only when the `"stale-if-error"` cache control directive 40 | is used, either in the request or in the response. 41 | 42 | As very few clients use it, you probably want to use it server side by setting 43 | this directive before returning the response in your controllers: 44 | 45 | conn 46 | |> put_resp_header("cache-control", "stale-if-error=600") 47 | ... 48 | 49 | One can also not "cache" response, but still keep staled versions to keep some pages 50 | showing even in case of serious trouble on the backend: 51 | 52 | conn 53 | |> put_resp_header("cache-control", "max-age=0, stale-if-error=600") 54 | ... 55 | 56 | Such a response will not be reused except by this module, in case of error. 57 | 58 | Note that as with the `Plug.ErrorHandler` module, error is reraised in any case. 59 | """ 60 | 61 | defmacro __using__(opts) do 62 | quote bind_quoted: [opts: opts] do 63 | opts = PlugHTTPCache.init(opts) 64 | 65 | @before_compile PlugHTTPCache.StaleIfError 66 | 67 | @__stale_if_error_opts__ opts 68 | end 69 | end 70 | 71 | @doc false 72 | defmacro __before_compile__(_env) do 73 | quote location: :keep do 74 | defoverridable call: 2 75 | 76 | def call(conn, opts) do 77 | try do 78 | super(conn, opts) 79 | rescue 80 | e in Plug.Conn.WrapperError -> 81 | %{conn: conn, kind: kind, reason: reason, stack: stack} = e 82 | 83 | PlugHTTPCache.StaleIfError.__handle_error__( 84 | conn, 85 | kind, 86 | e, 87 | reason, 88 | stack, 89 | @__stale_if_error_opts__ 90 | ) 91 | catch 92 | kind, reason -> 93 | PlugHTTPCache.StaleIfError.__handle_error__( 94 | conn, 95 | kind, 96 | reason, 97 | reason, 98 | __STACKTRACE__, 99 | @__stale_if_error_opts__ 100 | ) 101 | end 102 | end 103 | end 104 | end 105 | 106 | @already_sent {:plug_conn, :sent} 107 | 108 | @doc false 109 | def __handle_error__(conn, kind, e, reason, stack, http_cache_opts) do 110 | receive do 111 | @already_sent -> 112 | send(self(), @already_sent) 113 | 114 | conn 115 | after 116 | 0 -> 117 | request = PlugHTTPCache.request(conn) 118 | response = {Plug.Exception.status(reason), [{"cache-control", "no-store"}], ""} 119 | 120 | case :http_cache.cache(request, response, http_cache_opts) do 121 | {:not_cacheable, {resp_ref, response}} -> 122 | PlugHTTPCache.telemetry_log(:stale_if_error, conn) 123 | PlugHTTPCache.notify_and_send_response(conn, resp_ref, response, http_cache_opts) 124 | 125 | :not_cacheable -> 126 | conn 127 | end 128 | end 129 | 130 | :erlang.raise(kind, e, stack) 131 | end 132 | end 133 | -------------------------------------------------------------------------------- /media/grafana.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tanguilp/plug_http_cache/8c0e3aefa41a00512e387e2064104135d42dc192/media/grafana.png -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule PlugHTTPCache.MixProject do 2 | use Mix.Project 3 | 4 | def project do 5 | [ 6 | app: :plug_http_cache, 7 | description: "A Plug that caches HTTP responses", 8 | version: "0.4.0", 9 | elixir: "~> 1.18", 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/plug_http_cache" 19 | ] 20 | end 21 | 22 | # Run "mix help compile.app" to learn about applications. 23 | def application do 24 | [ 25 | extra_applications: [:logger] 26 | ] 27 | end 28 | 29 | # Run "mix help deps" to learn about dependencies. 30 | defp deps do 31 | [ 32 | {:dialyxir, "~> 1.0", only: [:dev], runtime: false}, 33 | {:ex_doc, "~> 0.24", only: :dev, runtime: false}, 34 | {:http_cache, "~> 0.4.0", optional: true}, 35 | {:http_cache_store_memory, "~> 0.3.0", only: :test}, 36 | {:phoenix, "~> 1.0", only: :test}, 37 | {:plug, "~> 1.0"}, 38 | {:plug_loopback, "~> 0.1.0"}, 39 | {:telemetry, "~> 1.0"} 40 | ] 41 | end 42 | 43 | def package() do 44 | [ 45 | licenses: ["Apache-2.0"], 46 | links: %{"GitHub" => "https://github.com/tanguilp/plug_http_cache"} 47 | ] 48 | end 49 | end 50 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "castore": {:hex, :castore, "1.0.13", "b735ea45d87f027128debaf662c6cdf618604f530bec554cc60c195f2a55b506", [:mix], [], "hexpm", "ec09e81a9c3db92d27c6651d119d8adc6d1cbbb3d90f8c1293eee2af590bf55d"}, 3 | "cowlib": {:hex, :cowlib, "2.15.0", "3c97a318a933962d1c12b96ab7c1d728267d2c523c25a5b57b0f93392b6e9e25", [:make, :rebar3], [], "hexpm", "4f00c879a64b4fe7c8fcb42a4281925e9ffdb928820b03c3ad325a617e857532"}, 4 | "dialyxir": {:hex, :dialyxir, "1.4.5", "ca1571ac18e0f88d4ab245f0b60fa31ff1b12cbae2b11bd25d207f865e8ae78a", [:mix], [{:erlex, ">= 0.2.7", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "b0fb08bb8107c750db5c0b324fa2df5ceaa0f9307690ee3c1f6ba5b9eb5d35c3"}, 5 | "earmark_parser": {:hex, :earmark_parser, "1.4.25", "2024618731c55ebfcc5439d756852ec4e85978a39d0d58593763924d9a15916f", [:mix], [], "hexpm", "56749c5e1c59447f7b7a23ddb235e4b3defe276afc220a6227237f3efe83f51e"}, 6 | "erlex": {:hex, :erlex, "0.2.7", "810e8725f96ab74d17aac676e748627a07bc87eb950d2b83acd29dc047a30595", [:mix], [], "hexpm", "3ed95f79d1a844c3f6bf0cea61e0d5612a42ce56da9c03f01df538685365efb0"}, 7 | "ex_doc": {:hex, :ex_doc, "0.28.4", "001a0ea6beac2f810f1abc3dbf4b123e9593eaa5f00dd13ded024eae7c523298", [: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", "bf85d003dd34911d89c8ddb8bda1a958af3471a274a4c2150a9c01c78ac3f8ed"}, 8 | "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"}, 9 | "http_cache_store_behaviour": {:hex, :http_cache_store_behaviour, "0.3.0", "d94116da52eb05a065c63b0d10baeb58b394c23478d7107aaadd270bef577ee5", [:rebar3], [], "hexpm", "45ecd47a0445364141c6e73ce6a64aca478f4473f9af0f509d8e4b4ae43640bd"}, 10 | "http_cache_store_memory": {:hex, :http_cache_store_memory, "0.3.2", "110c4f935f8dc0ed14ca51de967b82200db147f7848f692f3ff4d141ae5b6110", [:rebar3], [{: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", "919a01d0575c08c07dcd63c99560f98e20d3151b7bfba8fc82a6c7049227f5d6"}, 11 | "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"}, 12 | "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"}, 13 | "makeup_erlang": {:hex, :makeup_erlang, "0.1.1", "3fcb7f09eb9d98dc4d208f49cc955a34218fc41ff6b84df7c75b3e6e533cc65f", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "174d0809e98a4ef0b3309256cbf97101c6ec01c4ab0b23e926a9e17df2077cbb"}, 14 | "mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"}, 15 | "nimble_parsec": {:hex, :nimble_parsec, "1.2.3", "244836e6e3f1200c7f30cb56733fd808744eca61fd182f731eac4af635cc6d0b", [:mix], [], "hexpm", "c8d789e39b9131acf7b99291e93dae60ab48ef14a7ee9d58c6964f59efb570b0"}, 16 | "phoenix": {:hex, :phoenix, "1.7.21", "14ca4f1071a5f65121217d6b57ac5712d1857e40a0833aff7a691b7870fc9a3b", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "336dce4f86cba56fed312a7d280bf2282c720abb6074bdb1b61ec8095bdd0bc9"}, 17 | "phoenix_pubsub": {:hex, :phoenix_pubsub, "2.1.3", "3168d78ba41835aecad272d5e8cd51aa87a7ac9eb836eabc42f6e57538e3731d", [:mix], [], "hexpm", "bba06bc1dcfd8cb086759f0edc94a8ba2bc8896d5331a1e2c2902bf8e36ee502"}, 18 | "phoenix_template": {:hex, :phoenix_template, "1.0.4", "e2092c132f3b5e5b2d49c96695342eb36d0ed514c5b252a77048d5969330d639", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "2c0c81f0e5c6753faf5cca2f229c9709919aba34fab866d3bc05060c9c444206"}, 19 | "plug": {:hex, :plug, "1.17.0", "a0832e7af4ae0f4819e0c08dd2e7482364937aea6a8a997a679f2cbb7e026b2e", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "f6692046652a69a00a5a21d0b7e11fcf401064839d59d6b8787f23af55b1e6bc"}, 20 | "plug_crypto": {:hex, :plug_crypto, "2.1.1", "19bda8184399cb24afa10be734f84a16ea0a2bc65054e23a62bb10f06bc89491", [:mix], [], "hexpm", "6470bce6ffe41c8bd497612ffde1a7e4af67f36a15eea5f921af71cf3e11247c"}, 21 | "plug_loopback": {:hex, :plug_loopback, "0.1.0", "4b02c73980be163bc4cd5d4cb2001a58c2649c80dda9cf23d3a8f508de7da9e4", [:mix], [{:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "0683579cc923b33594c000e80c7d6388fb247a04bcdd62500e54275655a2e663"}, 22 | "telemetry": {:hex, :telemetry, "1.3.0", "fedebbae410d715cf8e7062c96a1ef32ec22e764197f70cda73d82778d61e7a2", [:rebar3], [], "hexpm", "7015fc8919dbe63764f4b4b87a95b7c0996bd539e0d499be6ec9d7f3875b79e6"}, 23 | "websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"}, 24 | "websock_adapter": {:hex, :websock_adapter, "0.5.8", "3b97dc94e407e2d1fc666b2fb9acf6be81a1798a2602294aac000260a7c4a47d", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "315b9a1865552212b5f35140ad194e67ce31af45bcee443d4ecb96b5fd3f3782"}, 25 | } 26 | -------------------------------------------------------------------------------- /test/plug_http_cache_test.exs: -------------------------------------------------------------------------------- 1 | defmodule PlugHttpCacheTest do 2 | use ExUnit.Case 3 | 4 | import Plug.Test 5 | 6 | @hit_telemetry_event [:plug_http_cache, :hit] 7 | @miss_telemetry_event [:plug_http_cache, :miss] 8 | 9 | defmodule Router do 10 | use Plug.Router 11 | 12 | plug(PlugHTTPCache, %{store: :http_cache_store_process}) 13 | plug(:match) 14 | plug(:dispatch) 15 | 16 | get "/page" do 17 | conn 18 | |> Plug.Conn.delete_resp_header("cache-control") 19 | |> send_resp(200, "some content") 20 | end 21 | end 22 | 23 | Application.put_env(:phoenix, Module.concat(__MODULE__, EndpointForRevalidate), []) 24 | 25 | defmodule EndpointForRevalidate do 26 | use Phoenix.Endpoint, otp_app: :phoenix 27 | 28 | def init(opts), do: opts 29 | 30 | def call(conn, _opts) do 31 | PlugHttpCacheTest.RouterForRevalidate.call( 32 | conn, 33 | PlugHttpCacheTest.RouterForRevalidate.init([]) 34 | ) 35 | end 36 | end 37 | 38 | defmodule RouterForRevalidate do 39 | # We need a global state to test support for `stale-while-revalidate`, because the revalidate 40 | # request is performed in another process. `http_cache_store_memory` provides with that 41 | use Plug.Router 42 | 43 | plug(PlugHTTPCache, %{store: :http_cache_store_memory}) 44 | plug(:match) 45 | plug(:dispatch) 46 | 47 | get "/stale/while/revalidate" do 48 | conn 49 | |> Plug.Conn.put_resp_header("cache-control", "max-age=0, stale-while-revalidate=60") 50 | |> send_resp(200, "some content") 51 | end 52 | end 53 | 54 | setup_all do 55 | EndpointForRevalidate.start_link() 56 | :ok 57 | end 58 | 59 | describe "call/2" do 60 | test "response is cached", %{test: test} do 61 | conn = conn(:get, "/page?#{URI.encode_www_form(to_string(test))}") 62 | request = {"GET", Plug.Conn.request_url(conn), [], ""} 63 | 64 | :telemetry.attach( 65 | test, 66 | @miss_telemetry_event, 67 | fn _, _, _, _ -> 68 | send(self(), {:telemetry_event, @miss_telemetry_event}) 69 | end, 70 | nil 71 | ) 72 | 73 | Router.call(conn, []) 74 | 75 | assert {:fresh, _} = :http_cache.get(request, %{store: :http_cache_store_process}) 76 | assert_receive {:telemetry_event, @miss_telemetry_event} 77 | end 78 | 79 | test "cached content is served", %{test: test} do 80 | conn = conn(:get, "/page?#{URI.encode_www_form(to_string(test))}") 81 | 82 | :telemetry.attach( 83 | test, 84 | @hit_telemetry_event, 85 | fn _, _, _, _ -> 86 | send(self(), {:telemetry_event, @hit_telemetry_event}) 87 | end, 88 | nil 89 | ) 90 | 91 | Router.call(conn, []) 92 | conn = Router.call(conn, []) 93 | 94 | assert_receive {:telemetry_event, @hit_telemetry_event} 95 | assert conn.status == 200 96 | assert [_] = Plug.Conn.get_resp_header(conn, "age") 97 | assert conn.resp_body == "some content" 98 | end 99 | 100 | test "non-cacheable content is not cached", %{test: test} do 101 | conn = 102 | conn(:get, "/page?#{URI.encode_www_form(to_string(test))}") 103 | |> Plug.Conn.put_req_header("cache-control", "no-store") 104 | 105 | request = {"GET", Plug.Conn.request_url(conn), [], ""} 106 | 107 | :telemetry.attach( 108 | test, 109 | @miss_telemetry_event, 110 | fn _, _, _, _ -> 111 | send(self(), {:telemetry_event, @miss_telemetry_event}) 112 | end, 113 | nil 114 | ) 115 | 116 | Router.call(conn, []) 117 | 118 | assert :http_cache.get(request, %{store: :http_cache_store_process}) == :miss 119 | assert_receive {:telemetry_event, @miss_telemetry_event} 120 | end 121 | 122 | test "stale-while-revalidate is supported" do 123 | conn = conn(:get, "/stale/while/revalidate") 124 | 125 | ref = :telemetry_test.attach_event_handlers(self(), [[:http_cache, :cache]]) 126 | 127 | EndpointForRevalidate.call(conn, []) 128 | EndpointForRevalidate.call(conn, []) 129 | 130 | assert_receive {[:http_cache, :cache], ^ref, _, %{cacheable: true}} 131 | assert_receive {[:http_cache, :cache], ^ref, _, %{cacheable: true}} 132 | end 133 | 134 | test "client max-stale is discarded" do 135 | # If it was not, then the user would be able to create infinite cycles because we use 136 | # `max-stale=0` when revalidating to prevent receiving again a stale response which will 137 | # trigger another revalidation and so on 138 | 139 | conn = 140 | :get 141 | |> conn("/stale/while/revalidate") 142 | |> Plug.Conn.put_req_header("cache-control", "max-stale=3600") 143 | 144 | ref = :telemetry_test.attach_event_handlers(self(), [[:http_cache, :cache]]) 145 | 146 | EndpointForRevalidate.call(conn, []) 147 | 148 | :timer.sleep(2_000) 149 | 150 | {_, messages} = :erlang.process_info(self(), :messages) 151 | 152 | # An infinite cycle would create tons of telemetry messages 153 | assert length(messages) < 100 154 | end 155 | end 156 | 157 | describe "set_alternate_keys/2" do 158 | test "alternate key is used to invalidate entry", %{test: test} do 159 | conn = conn(:get, "/page?#{URI.encode_www_form(to_string(test))}") 160 | 161 | conn 162 | |> PlugHTTPCache.set_alternate_keys(:some_alt_key) 163 | |> PlugHTTPCache.set_alternate_keys([:some_other_alt_key]) 164 | |> Router.call([]) 165 | 166 | :timer.sleep(10) 167 | 168 | :http_cache.invalidate_by_alternate_key(:some_alt_key, %{store: :http_cache_store_process}) 169 | conn = Router.call(conn, []) 170 | 171 | assert Plug.Conn.get_resp_header(conn, "age") == [] 172 | end 173 | end 174 | end 175 | -------------------------------------------------------------------------------- /test/plug_http_cache_test/stale_if_error_test.exs: -------------------------------------------------------------------------------- 1 | defmodule PlugHTTPCache.StaleIfErrorTest do 2 | use ExUnit.Case 3 | 4 | import Plug.Test 5 | 6 | @http_cache_opts %{store: :http_cache_store_process} 7 | @stale_returned_telemetry_event [:plug_http_cache, :stale_if_error] 8 | 9 | defmodule Router do 10 | use Plug.Router 11 | use PlugHTTPCache.StaleIfError, %{:type => :shared, :store => :http_cache_store_process} 12 | use Plug.ErrorHandler 13 | 14 | plug(:match) 15 | plug(:dispatch) 16 | 17 | get "/boom" do 18 | raise "oops" 19 | end 20 | end 21 | 22 | test "stale response is returned when header is set in response", %{test: test} do 23 | conn = conn(:get, "/boom") 24 | 25 | :telemetry.attach( 26 | test, 27 | @stale_returned_telemetry_event, 28 | fn _, _, _, _ -> 29 | send(self(), {:telemetry_event, @stale_returned_telemetry_event}) 30 | end, 31 | nil 32 | ) 33 | 34 | request = {"GET", Plug.Conn.request_url(conn), [], ""} 35 | response = {200, [{"cache-control", "stale-if-error=30, max-age=0"}], "Some response"} 36 | :http_cache.cache(request, response, @http_cache_opts) 37 | 38 | assert_raise Plug.Conn.WrapperError, "** (RuntimeError) oops", fn -> 39 | Router.call(conn, []) 40 | end 41 | 42 | assert_receive {:plug_conn, :sent} 43 | assert_receive {:telemetry_event, @stale_returned_telemetry_event} 44 | assert {200, _headers, "Some response"} = sent_resp(conn) 45 | end 46 | 47 | test "stale response is returned when header is set in request", %{test: test} do 48 | conn = 49 | conn(:get, "/boom") 50 | |> Plug.Conn.put_req_header("cache-control", "stale-if-error=30") 51 | 52 | :telemetry.attach( 53 | test, 54 | @stale_returned_telemetry_event, 55 | fn _, _, _, _ -> 56 | send(self(), {:telemetry_event, @stale_returned_telemetry_event}) 57 | end, 58 | nil 59 | ) 60 | 61 | request = {"GET", Plug.Conn.request_url(conn), conn.req_headers, ""} 62 | response = {200, [{"cache-control", "max-age=0"}], "Some response"} 63 | :http_cache.cache(request, response, @http_cache_opts) 64 | 65 | assert_raise Plug.Conn.WrapperError, "** (RuntimeError) oops", fn -> 66 | Router.call(conn, []) 67 | end 68 | 69 | assert_receive {:plug_conn, :sent} 70 | assert_receive {:telemetry_event, @stale_returned_telemetry_event} 71 | assert {200, _headers, "Some response"} = sent_resp(conn) 72 | end 73 | 74 | test "stale response is not returned when header is missing" do 75 | conn = conn(:get, "/boom") 76 | 77 | request = {"GET", Plug.Conn.request_url(conn), [], ""} 78 | response = {200, [{"cache-control", "max-age=0"}], "Some response"} 79 | :http_cache.cache(request, response, @http_cache_opts) 80 | 81 | assert_raise Plug.Conn.WrapperError, "** (RuntimeError) oops", fn -> 82 | Router.call(conn, []) 83 | end 84 | 85 | refute match?({200, _headers, _body}, sent_resp(conn)) 86 | end 87 | end 88 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | --------------------------------------------------------------------------------