Skip to content

Commit

Permalink
Change default cache-control response to 'no-store'
Browse files Browse the repository at this point in the history
The default cache-control header returned by sse-starlete is
"no-cache", which (confusingly!) does not actually mean "don't cache",
but instead that caching is allowed but the result must be
re-validated on each fetch.  [From MDN](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control):

> The no-cache response directive indicates that the response can be
> stored in caches, but the response must be validated with the origin
> server before each reuse, even when the cache is disconnected from
> the origin server.

This is *probably* harmless as long as there's not an ETag or
Last-Modified header that would allow revalidation, but it does make
it much easier to unexpectedly enable caching or globally audit cache
behavior.

This PR:

 * Changes the default to "no-store", which does disallow caching

 * Uses starlette's `MutableHeaders` to build the headers; this
   normalizes to all lower-case so we're independent of case if an
   explicit cache-control is passed in.

 * Adds a test for header handling.

 * Adds ruff to the dev dependencies and updates `make lint` to
   resolve a deprecation warning in the command.
  • Loading branch information
ahupp committed Jul 21, 2024
1 parent a918e84 commit a54ed6f
Show file tree
Hide file tree
Showing 4 changed files with 43 additions and 4 deletions.
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ style: sort-imports format ## perform code style format (black, isort)

.PHONY: lint
lint: ## check style with ruff
ruff $(pkg_src) $(tests_src)
ruff check $(pkg_src) $(tests_src)

.PHONY: mypy
mypy: ## check type hint annotations
Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ dev = [
"asgi-lifespan",
"psutil",
"black>=23.11.0",
"ruff",
]

[tool.isort]
Expand Down
11 changes: 8 additions & 3 deletions sse_starlette/sse.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
from starlette.concurrency import iterate_in_threadpool
from starlette.responses import Response
from starlette.types import Receive, Scope, Send
from starlette.datastructures import MutableHeaders

_log = logging.getLogger(__name__)

Expand Down Expand Up @@ -196,14 +197,18 @@ def __init__(
self.data_sender_callable = data_sender_callable
self.send_timeout = send_timeout

_headers: dict[str, str] = {}
_headers = MutableHeaders()
if headers is not None: # pragma: no cover
_headers.update(headers)

# mandatory for servers-sent events headers
# "The no-store response directive indicates that any caches of any kind (private or shared)
# should not store this response."
# -- https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control
# allow cache control header to be set by user to support fan out proxies
# https://www.fastly.com/blog/server-sent-events-fastly
_headers.setdefault("Cache-Control", "no-cache")

_headers.setdefault("Cache-Control", "no-store")
# mandatory for servers-sent events headers
_headers["Connection"] = "keep-alive"
_headers["X-Accel-Buffering"] = "no"

Expand Down
33 changes: 33 additions & 0 deletions tests/test_event_source_response.py
Original file line number Diff line number Diff line change
Expand Up @@ -171,3 +171,36 @@ async def receive():
await response({}, receive, send)

assert cleanup


def test_headers_with_override():
generator = range(1, 10)
response = EventSourceResponse(generator, ping=0.2) # type: ignore
default_headers = dict((h.decode(), v.decode()) for h, v in response.raw_headers)
assert default_headers == {
"cache-control": "no-store",
"x-accel-buffering": "no",
"connection": "keep-alive",
"content-type": "text/event-stream; charset=utf-8",
}

custom_headers = {
# cache-control can be overridden
"cache-control": "no-cache",
# x-accel-buffering and connection cannot
"x-accel-buffering": "yes",
"connection": "close",
# other headers are allowed
"x-my-special-header": "37",
}
generator = range(1, 10)
response = EventSourceResponse(generator, headers=custom_headers, ping=0.2) # type: ignore
override_headers = dict((h.decode(), v.decode()) for h, v in response.raw_headers)

assert override_headers == {
"cache-control": "no-cache",
"x-accel-buffering": "no",
"connection": "keep-alive",
"x-my-special-header": "37",
"content-type": "text/event-stream; charset=utf-8",
}

0 comments on commit a54ed6f

Please sign in to comment.