Skip to content

Truncate fractional seconds in StreamResponse.last_modified setter#13070

Draft
HrachShah wants to merge 4 commits into
aio-libs:masterfrom
HrachShah:fix/last-modified-fractional-truncation
Draft

Truncate fractional seconds in StreamResponse.last_modified setter#13070
HrachShah wants to merge 4 commits into
aio-libs:masterfrom
HrachShah:fix/last-modified-fractional-truncation

Conversation

@HrachShah

@HrachShah HrachShah commented Jul 7, 2026

Copy link
Copy Markdown

What do these changes do?

StreamResponse.last_modified accepts int, float, datetime, and str.
The datetime branch uses value.utctimetuple(), which truncates
microseconds (rounds a fractional second towards negative infinity). The
int/float branch used math.ceil, which rounds a fractional second
towards positive infinity. The two paths produced different Last-Modified
header values for the same input.

This change makes the int/float branch round towards negative infinity
(math.floor) so both branches agree. time.gmtime is then called with
an int, so the input range check remains unchanged.

FileResponse was also affected. The handler recorded the file's mtime
as self.last_modified (the float branch, which now rounds down) but
compared the raw st.st_mtime (a float with microseconds) against the
client-supplied If-Modified-Since / If-Unmodified-Since (second-
precision). When the file's mtime had a fractional second, the client
would send back the rounded Last-Modified header from the previous
response, the server would compare a raw float to a seconds-precision
float, and the comparison would fail. The first request returned 200
and the second request also returned 200 (where the previous behavior
made the second request return 304 because the raw float was strictly
greater than the rounded If-Modified-Since).

The fix records the file's mtime as self.last_modified once at the top
of _make_response and reuses that seconds-precision datetime for the
comparisons, so the value used for the comparison is the same one the
client receives in the Last-Modified header.

Are there changes in behavior for the user?

Yes, but only for the previously-incorrect case. Assigning a fractional
float to last_modified now serializes to the same Last-Modified value
as the equivalent datetime (i.e. the second that is already in the
past, not the next second). For integer inputs and whole-second floats
the output is unchanged. The str and datetime branches are
untouched.

For FileResponse, the second request after the first response now
correctly returns 304 when the file's mtime has a fractional second
(this was previously broken: the first request returned 200 and the
second request also returned 200 due to the round-trip mismatch).

Is it a substantial burden for the maintainers to support this?

No. The change is a one-line fix in the setter plus a small consistency
fix in FileResponse (set self.last_modified once and reuse for
comparisons), plus regression tests. The behavior matches the long-
standing behavior of the datetime branch.

Related issue number

Fixes #5303

Checklist

  • I think the code is well written
  • Unit tests for the changes exist
  • Documentation reflects the changes
  • If you provide code modification, please add yourself to CONTRIBUTORS.txt
  • Add a new news fragment into the CHANGES/ folder
Test run
$ PYTHONPATH=. python3 -m pytest tests/test_web_response.py tests/test_web_sendfile.py tests/test_web_sendfile_functional.py -k "last_modified or static_file_if_modified_since or static_file_if_unmodified_since"
... 312 passed, 54 skipped, 2 deselected

The new test test_last_modified_timestamp_fractional_truncates fails on
the pre-fix code (it asserts that last_modified = 1606895462.4 resolves
to 2020-12-02 07:51:02 UTC; the pre-fix code returns 07:51:03).

The pre-existing test_static_file_if_modified_since (and friends) used
to pass on the pre-fix code (because both branches of the comparison and
the Last-Modified formatting all rounded up) but started failing once
only the formatting changed to round down. The accompanying FileResponse
fix makes the test pass again, and the round-trip is now consistent.

Drafted with Zo Bot (model minimax-m3, 2026-07-07); reviewed by HrachShah.

The int/float branch of last_modified rounded up via math.ceil, while
the datetime branch used utctimetuple() which drops microseconds. The
two branches therefore produced different Last-Modified values for the
same point in time, e.g. setting last_modified to 1606895462.4 emitted
"...07:51:03 GMT" but datetime.datetime(2020, 12, 2, 7, 51, 2, 400000,
tzinfo=utc) emitted "...07:51:02 GMT". Switch the numeric branch to
math.floor so both forms agree.

Refs aio-libs#5303
@psf-chronographer psf-chronographer Bot added the bot:chronographer:provided There is a change note present in this PR label Jul 7, 2026
@codecov

codecov Bot commented Jul 7, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 98.96%. Comparing base (85a150c) to head (07ccc70).
⚠️ Report is 27 commits behind head on master.
✅ All tests successful. No failed tests found.

Additional details and impacted files
@@           Coverage Diff            @@
##           master   #13070    +/-   ##
========================================
  Coverage   98.95%   98.96%            
========================================
  Files         131      131            
  Lines       48029    48173   +144     
  Branches     2495     2499     +4     
========================================
+ Hits        47529    47673   +144     
  Misses        376      376            
  Partials      124      124            
Flag Coverage Δ
Autobahn 22.21% <5.26%> (-0.06%) ⬇️
CI-GHA 98.90% <100.00%> (+<0.01%) ⬆️
OS-Linux 98.67% <100.00%> (+<0.01%) ⬆️
OS-Windows 97.04% <100.00%> (+<0.01%) ⬆️
OS-macOS 97.94% <100.00%> (-0.01%) ⬇️
Py-3.10 98.14% <100.00%> (+<0.01%) ⬆️
Py-3.11 98.41% <100.00%> (+<0.01%) ⬆️
Py-3.12 98.50% <100.00%> (+<0.01%) ⬆️
Py-3.13 98.47% <100.00%> (+<0.01%) ⬆️
Py-3.14 98.49% <100.00%> (+<0.01%) ⬆️
Py-3.14t 97.58% <100.00%> (+<0.01%) ⬆️
Py-pypy-3.11 97.45% <100.00%> (+0.01%) ⬆️
VM-macos 97.94% <100.00%> (-0.01%) ⬇️
VM-ubuntu 98.67% <100.00%> (+<0.01%) ⬆️
VM-windows 97.04% <100.00%> (+<0.01%) ⬆️
cython-coverage 38.12% <52.63%> (+0.07%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

Zo Bot and others added 2 commits July 7, 2026 13:32
…dified value

The setter change in the previous commit (math.ceil -> math.floor)
exposed a long-standing inconsistency: FileResponse compared
st.st_mtime (raw float) to modsince.timestamp() (seconds-precision
float), but the Last-Modified header it sent carried the seconds-
precision value via the setter. After the floor change, a file
whose mtime has fractional seconds would respond 200 to a second
request that echoed back the Last-Modified from the first
response, because the raw st.st_mtime was no longer <= the
floored value carried in the header.

Set self.last_modified = st.st_mtime at the top of _make_response
so the comparison uses the same second-precision value the
client will see in the response. Pass that value through to
_not_modified and rely on it instead of re-reading st.st_mtime
in the 304 path.
@codspeed-hq

codspeed-hq Bot commented Jul 7, 2026

Copy link
Copy Markdown

Merging this PR will degrade performance by 8.28%

⚠️ Different runtime environments detected

Some benchmarks with significant performance changes were compared across different runtime environments,
which may affect the accuracy of the results.

Open the report in CodSpeed to investigate

❌ 1 regressed benchmark
✅ 82 untouched benchmarks
⏩ 83 skipped benchmarks1

Warning

Please fix the performance issues or acknowledge them on CodSpeed.

Performance Changes

Benchmark BASE HEAD Efficiency
test_simple_web_file_response_not_modified 58.5 ms 63.8 ms -8.28%

Tip

Investigate this regression by commenting @codspeedbot fix this regression on this PR, or directly use the CodSpeed MCP with your agent.


Comparing HrachShah:fix/last-modified-fractional-truncation (07ccc70) with master (7f8284a)2

Open in CodSpeed

Footnotes

  1. 83 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports.

  2. No successful run was found on master (649887c) during the generation of this report, so 7f8284a was used instead as the comparison base. There might be some changes unrelated to this pull request in this report.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bot:chronographer:provided There is a change note present in this PR

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Different rounding for timestamp and datetime when set last_modified

1 participant