Skip to content

Fix timedelta date headers (e.g. Response.expires) to be UTC-based (fixes #430)#491

Open
apoorvdarshan wants to merge 1 commit into
Pylons:mainfrom
apoorvdarshan:fix/issue-430-expires-timedelta-utc
Open

Fix timedelta date headers (e.g. Response.expires) to be UTC-based (fixes #430)#491
apoorvdarshan wants to merge 1 commit into
Pylons:mainfrom
apoorvdarshan:fix/issue-430-expires-timedelta-utc

Conversation

@apoorvdarshan

Copy link
Copy Markdown

Fixes #430

Root cause

serialize_date() in src/webob/datetime_utils.py handles a timedelta by adding it to _now(), where _now = datetime.now returns naive local time:

if isinstance(dt, timedelta):
    dt = _now() + dt          # naive LOCAL time
...
dt = calendar.timegm(dt.timetuple())   # interprets the tuple as UTC
...
return formatdate(dt, usegmt=True)     # labels it GMT

The local wall-clock time is then fed to calendar.timegm (which treats its argument as UTC) and serialized with usegmt=True. So a local time is mislabeled as GMT, and the resulting header is off by the machine's UTC offset. Response.expires = <timedelta> goes through this path, as does anything that sets a date header from a timedelta. Response.cache_expires already does the right thing by using datetime.utcnow().

Reproduction

Running under a non-UTC timezone (here America/New_York, UTC-4 in July), setting a zero timedelta should yield an Expires equal to "now" in UTC:

from datetime import datetime, timedelta
from webob.datetime_utils import serialize_date

# datetime.utcnow() at the time of running: 16:31:55 UTC
serialize_date(timedelta(seconds=0))

Actual (wrong) output:

'Thu, 09 Jul 2026 12:30:18 GMT'      # 4 hours behind real UTC now (16:30)

Response.expires then reads back the wrong value:

from webob import Response
r = Response(); r.expires = timedelta(seconds=0)
r.expires   # -> datetime(2026, 7, 9, 12, 30, 18, tzinfo=UTC)  -- off by -4h

This matches the original report from CEST (off by +2h).

Corrected output (same moment, after the fix):

'Thu, 09 Jul 2026 16:31:55 GMT'      # matches UTC now; skew 0s

Verified skew is 0 seconds under America/New_York, Europe/Berlin, Asia/Kolkata, and UTC.

Fix

In the timedelta branch of serialize_date, add the delta to UTC now instead of naive local time, mirroring Response._cache_expires:

if isinstance(dt, timedelta):
    dt = _utcnow() + dt

A _utcnow = datetime.utcnow hook point is added next to the existing _now hook, keeping the same test-seam style already used in the module. The change is a single line in the affected branch; _now and parse_date_delta are left untouched to keep the fix localized to the reported behavior.

Tests

  • Added test_serialize_date_timedelta_is_utc_based, which runs under a non-UTC timezone (TZ=America/New_York, restored afterward) and asserts the serialized zero-delta header falls within a one-second window of datetime.utcnow(). It fails on the unpatched code (header is 4 hours behind) and passes after the fix.
  • Updated the existing test_serialize_date to compute its expected value from utcnow() instead of now(), so it asserts the correct (timezone-independent) behavior rather than enshrining the bug.
  • Full suite: 2299 passed, 1 xfailed. black --check, isort --check-only, and flake8 are clean on the changed files.

Disclosure: prepared with AI assistance; reviewed and verified locally.

serialize_date() added a timedelta to naive local time (_now()) but then
serialized the result as GMT (usegmt=True, via calendar.timegm), so setting
a date header such as Response.expires from a timedelta produced a value off
by the machine's UTC offset.

Add the delta to UTC now instead, matching Response._cache_expires, which
already uses utcnow(). A new hook point (_utcnow) mirrors the existing _now
hook. Adds a regression test that serializes a zero timedelta under a non-UTC
timezone and asserts the header matches UTC now, and updates the existing
serialize_date test to compute its expected value from utcnow() so it is
timezone-independent.

Signed-off-by: Apoorv Darshan <ad13dtu@gmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Setting response.expires via timedelta produces an incorrect header value

1 participant