Skip to content

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

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

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
Contributor

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>
@digitalresistor

Copy link
Copy Markdown
Member

Thank you for this — the diagnosis in your PR description was exactly right, and the tz-sensitive test approach was the thing that made the bug demonstrable.

There were three overlapping PRs open against #473 and #430 (this one, #475 and #480), all touching the same lines, so none could be merged alongside the others. I've combined them into #496, where your commit is preserved with you as the author.

Two adjustments worth flagging:

  • Rather than adding a _utcnow = datetime.utcnow hook, Replace deprecated utcnow() and fix UTC-based timedelta date headers #496 repoints the existing _now at the utcnow() helper from Replace deprecated datetime.datetime.utcnow #480. datetime.utcnow() is the call deprecated in Python 3.13 deprecation of utcnow() #473, so adding a new use of it in datetime_utils would have reintroduced the very thing that branch removes. Your test's two datetime.datetime.utcnow() calls were switched to the helper for the same reason.

  • Repointing _now also fixes a second occurrence of the same bug that your patch doesn't reach: parse_date_delta() resolved delta seconds against naive local time too, so reading back Response.retry_after returned an instant off by the local UTC offset. That now has its own regression test alongside yours.

Verified end-to-end under TZ=Europe/Berlin: Response.expires = timedelta(seconds=0) went from 2 hours in the future to 0s skew, matching the CEST report in #430.

Closing here since the work continues in #496 — it's open for review, not merged yet.

digitalresistor pushed a commit that referenced this pull request Aug 3, 2026
serialize_date() added the delta to _now(), which was datetime.now and so
naive *local* time. The result is then fed to calendar.timegm (which reads its
argument as UTC) and serialized with usegmt=True, so the header came out off by
the machine's UTC offset.

Adapted from #491 for this branch:

- Rather than adding a _utcnow = datetime.utcnow hook, _now is now the
  utcnow() helper introduced alongside the #473 deprecation work. #491's
  original form would have reintroduced the deprecated call this branch
  removes, in the same module.

- Repointing _now fixes the second occurrence of the same bug, in
  parse_date_delta(), which resolved delta seconds against local time and made
  e.g. Response.retry_after return an instant off by the UTC offset. Covered by
  test_parse_date_delta_is_utc_based.

Keeping the single _now hook means the existing _NowRestorer tests continue to
apply to both call sites.
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

2 participants