Skip to content

Commit 0b8fe43

Browse files
authored
Merge pull request #185 from lsst-sqre/tickets/DM-50042
DM-50042: Set Expires and Cache-Control on links replies
2 parents b63b596 + dfc1dd1 commit 0b8fe43

4 files changed

Lines changed: 30 additions & 0 deletions

File tree

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
### New features
2+
3+
- Set `Expires` and `Cache-Control` headers on the links reply reflecting the expiration time of signed image URLs, informing clients that the response should not be cached beyond the expiration of those URLs. The lifetime of the links is specified as a new configuration option for now. That option will be removed once that lifetime is available from Butler.

src/datalinker/config.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,14 @@
22

33
from __future__ import annotations
44

5+
from datetime import timedelta
56
from pathlib import Path
67
from typing import Annotated
78

89
from pydantic import Field, HttpUrl, SecretStr
910
from pydantic_settings import BaseSettings, SettingsConfigDict
1011
from safir.logging import LogLevel, Profile
12+
from safir.pydantic import HumanTimedelta
1113

1214
__all__ = [
1315
"Config",
@@ -42,6 +44,14 @@ class Config(BaseSettings):
4244
),
4345
] = "/api/hips"
4446

47+
links_lifetime: Annotated[
48+
HumanTimedelta,
49+
Field(
50+
title="Lifetime of image links replies",
51+
description="Should match the lifetime of signed URLs from Butler",
52+
),
53+
] = timedelta(hours=1)
54+
4555
log_level: Annotated[
4656
LogLevel,
4757
Field(title="Log level of the application's logger"),

src/datalinker/handlers/external.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
"""Handlers for the app's external root, ``/datalinker/``."""
22

3+
from datetime import UTC, datetime
4+
from email.utils import format_datetime
35
from typing import Annotated, Literal
46
from urllib.parse import urlencode
57

@@ -276,6 +278,8 @@ def links(
276278
],
277279
)
278280

281+
lifetime = int(config.links_lifetime.total_seconds())
282+
expires = datetime.now(tz=UTC) + config.links_lifetime
279283
return _TEMPLATES.TemplateResponse(
280284
request,
281285
"links.xml",
@@ -286,5 +290,9 @@ def links(
286290
"image_size": image_uri.size(),
287291
"cutout_sync_url": str(config.cutout_sync_url),
288292
},
293+
headers={
294+
"Cache-Control": f"max-age={lifetime}",
295+
"Expires": format_datetime(expires, usegmt=True),
296+
},
289297
media_type="application/x-votable+xml",
290298
)

tests/handlers/external_test.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
from __future__ import annotations
44

5+
from datetime import timedelta
6+
from email.utils import parsedate_to_datetime
57
from unittest.mock import patch
68
from urllib.parse import parse_qs, urlparse
79
from uuid import uuid4
@@ -10,6 +12,7 @@
1012
from httpx import AsyncClient
1113
from jinja2 import Environment, PackageLoader, select_autoescape
1214
from lsst.daf.butler import LabeledButlerFactory
15+
from safir.datetime import current_datetime
1316

1417
from datalinker.config import config
1518

@@ -194,13 +197,19 @@ async def test_links(client: AsyncClient, mock_butler: MockButler) -> None:
194197
f"https://presigned-url.example.com/{mock_butler.uuid!s}"
195198
"?X-Amz-Signature=abcdef"
196199
)
200+
expected_expires = current_datetime() + config.links_lifetime
197201

198202
# Use iD to test the IVOA requirement of case insensitive parameters.
199203
r = await client.get(
200204
"/api/datalink/links",
201205
params={"iD": f"butler://label-http/{mock_butler.uuid!s}"},
202206
)
203207
assert r.status_code == 200
208+
expires = parsedate_to_datetime(r.headers["Expires"])
209+
assert expected_expires <= expires
210+
assert expires <= expected_expires + timedelta(seconds=5)
211+
lifetime = int(config.links_lifetime.total_seconds())
212+
assert r.headers["Cache-Control"] == f"max-age={lifetime}"
204213

205214
# The URL is already signed, so it should be passed through unchanged
206215
env = Environment(

0 commit comments

Comments
 (0)