Merge ~cjwatson/lazr.restful:304-response-headers into lazr.restful:main

Proposed by Colin Watson
Status: Merged
Merged at revision: 10efe471f131ad91fac1f2993dcca93fe2679115
Proposed branch: ~cjwatson/lazr.restful:304-response-headers
Merge into: lazr.restful:main
Diff against target: 251 lines (+69/-17)
10 files modified
NEWS.rst (+2/-0)
src/lazr/restful/_resource.py (+22/-4)
src/lazr/restful/basic-site.zcml (+1/-0)
src/lazr/restful/example/base/filemanager.py (+2/-1)
src/lazr/restful/example/base/tests/entry.txt (+15/-3)
src/lazr/restful/example/base/tests/field.txt (+5/-1)
src/lazr/restful/example/base/tests/hostedfile.txt (+4/-1)
src/lazr/restful/example/base/tests/root.txt (+8/-3)
src/lazr/restful/publisher.py (+5/-1)
src/lazr/restful/testing/webservice.py (+5/-3)
Reviewer Review Type Date Requested Status
Guruprasad Approve
Review via email: mp+453766@code.launchpad.net

Commit message

Don't send superfluous HTTP headers with 304 responses

Description of the change

RFC 2616 section 10.3.5 and RFC 7232 section 4.1 are both clear that 304 responses SHOULD NOT (or in some cases MUST NOT) include headers other than a short list relevant to caching. In particular, sending `Content-Length: 0` is actively harmful because it can end up instructing caches to replace that header in their cached 200 response, resulting in incorrect empty cached responses.

I wasn't able to get rid of the `X-Powered-By:` header added by `zope.publisher`, but that's a fixed string and doesn't really matter.

To post a comment you must log in.
Revision history for this message
Guruprasad (lgp171188) wrote :

LGTM 👍

review: Approve

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
diff --git a/NEWS.rst b/NEWS.rst
index e2a3c4c..bb1e578 100644
--- a/NEWS.rst
+++ b/NEWS.rst
@@ -5,6 +5,8 @@ NEWS for lazr.restful
52.0.252.0.2
6=====6=====
77
8- Don't send superfluous HTTP headers with 304 responses (bug 2039251).
9
82.0.1 (2022-06-28)102.0.1 (2022-06-28)
9==================11==================
1012
diff --git a/src/lazr/restful/_resource.py b/src/lazr/restful/_resource.py
index d6b1919..6aceaa5 100644
--- a/src/lazr/restful/_resource.py
+++ b/src/lazr/restful/_resource.py
@@ -65,7 +65,7 @@ from zope.location.interfaces import ILocation
65from zope.pagetemplate.engine import TrustedAppPT65from zope.pagetemplate.engine import TrustedAppPT
66from zope.pagetemplate.pagetemplatefile import PageTemplateFile66from zope.pagetemplate.pagetemplatefile import PageTemplateFile
67from zope.proxy import isProxy67from zope.proxy import isProxy
68from zope.publisher.http import init_status_codes, status_reasons68from zope.publisher.http import DirectResult, init_status_codes, status_reasons
69from zope.publisher.interfaces import NotFound69from zope.publisher.interfaces import NotFound
70from zope.publisher.interfaces.http import IHTTPRequest70from zope.publisher.interfaces.http import IHTTPRequest
71from zope.schema import ValidationError, getFieldsInOrder71from zope.schema import ValidationError, getFieldsInOrder
@@ -357,6 +357,24 @@ class HTTPResource:
357 if existing_etag in incoming_etags:357 if existing_etag in incoming_etags:
358 # The client already has this representation.358 # The client already has this representation.
359 # No need to send it again.359 # No need to send it again.
360 # RFC 7232 section 4.1 says that 304 responses SHOULD NOT
361 # generate headers other than the following, unless they
362 # exist for the purpose of guiding cache updates.
363 keep_headers = {
364 name: self.request.response.getHeader(name)
365 for name in (
366 "Cache-Control",
367 "Content-Location",
368 "Date",
369 "ETag",
370 "Expires",
371 "Vary",
372 )
373 }
374 self.request.response.reset()
375 for name, value in sorted(keep_headers.items()):
376 if value is not None:
377 self.request.response.setHeader(name, value)
360 self.request.response.setStatus(304) # Not Modified378 self.request.response.setStatus(304) # Not Modified
361 media_type = None379 media_type = None
362 return media_type380 return media_type
@@ -1341,7 +1359,7 @@ class EntryFieldResource(FieldUnmarshallerMixin, EntryManipulatingResource):
1341 media_type = self.handleConditionalGET()1359 media_type = self.handleConditionalGET()
1342 if media_type is None:1360 if media_type is None:
1343 # The conditional GET succeeded. Serve nothing.1361 # The conditional GET succeeded. Serve nothing.
1344 return b""1362 return DirectResult(())
1345 else:1363 else:
1346 self.request.response.setHeader("Content-Type", media_type)1364 self.request.response.setHeader("Content-Type", media_type)
1347 return self._representation(media_type).encode()1365 return self._representation(media_type).encode()
@@ -1630,7 +1648,7 @@ class EntryResource(
1630 media_type = self.handleConditionalGET()1648 media_type = self.handleConditionalGET()
1631 if media_type is None:1649 if media_type is None:
1632 # The conditional GET succeeded. Serve nothing.1650 # The conditional GET succeeded. Serve nothing.
1633 return b""1651 return DirectResult(())
1634 else:1652 else:
1635 self.request.response.setHeader("Content-Type", media_type)1653 self.request.response.setHeader("Content-Type", media_type)
1636 return self._representation(media_type).encode()1654 return self._representation(media_type).encode()
@@ -2011,7 +2029,7 @@ class ServiceRootResource(HTTPResource):
2011 self.setCachingHeaders()2029 self.setCachingHeaders()
2012 if media_type is None:2030 if media_type is None:
2013 # The conditional GET succeeded. Serve nothing.2031 # The conditional GET succeeded. Serve nothing.
2014 return b""2032 return DirectResult(())
2015 elif media_type in [self.WADL_TYPE, self.DEPRECATED_WADL_TYPE]:2033 elif media_type in [self.WADL_TYPE, self.DEPRECATED_WADL_TYPE]:
2016 result = self.toWADL()2034 result = self.toWADL()
2017 elif media_type == self.JSON_TYPE:2035 elif media_type == self.JSON_TYPE:
diff --git a/src/lazr/restful/basic-site.zcml b/src/lazr/restful/basic-site.zcml
index 3e7eb2e..88e8901 100644
--- a/src/lazr/restful/basic-site.zcml
+++ b/src/lazr/restful/basic-site.zcml
@@ -8,6 +8,7 @@
8 <include package="zope.component" file="meta.zcml"/>8 <include package="zope.component" file="meta.zcml"/>
9 <include package="zope.security" file="meta.zcml"/>9 <include package="zope.security" file="meta.zcml"/>
10 <include package="zope.component"/>10 <include package="zope.component"/>
11 <include package="zope.publisher"/>
11 <include package="zope.traversing"/>12 <include package="zope.traversing"/>
12 <include package="grokcore.component" file="meta.zcml" />13 <include package="grokcore.component" file="meta.zcml" />
13 <include package="lazr.enum"/>14 <include package="lazr.enum"/>
diff --git a/src/lazr/restful/example/base/filemanager.py b/src/lazr/restful/example/base/filemanager.py
index 98c70ce..6d35a63 100644
--- a/src/lazr/restful/example/base/filemanager.py
+++ b/src/lazr/restful/example/base/filemanager.py
@@ -9,6 +9,7 @@ import hashlib
99
10import grokcore.component10import grokcore.component
11from zope.interface import implementer11from zope.interface import implementer
12from zope.publisher.http import DirectResult
1213
13from lazr.restful import ReadOnlyResource14from lazr.restful import ReadOnlyResource
14from lazr.restful.example.base.interfaces import IFileManager15from lazr.restful.example.base.interfaces import IFileManager
@@ -64,7 +65,7 @@ class ManagedFileResource(ReadOnlyResource):
64 incoming_etag = request.getHeader("If-None-Match")65 incoming_etag = request.getHeader("If-None-Match")
65 if incoming_etag == self.etag:66 if incoming_etag == self.etag:
66 response.setStatus(304)67 response.setStatus(304)
67 return68 return DirectResult(())
6869
69 response.setHeader("Content-Type", self.mediaType)70 response.setHeader("Content-Type", self.mediaType)
70 response.setHeader("Last-Modified", self.last_modified.isoformat())71 response.setHeader("Last-Modified", self.last_modified.isoformat())
diff --git a/src/lazr/restful/example/base/tests/entry.txt b/src/lazr/restful/example/base/tests/entry.txt
index 045bd14..e888ca7 100644
--- a/src/lazr/restful/example/base/tests/entry.txt
+++ b/src/lazr/restful/example/base/tests/entry.txt
@@ -561,7 +561,11 @@ changed.
561 >>> print(webservice.get(greens_url,561 >>> print(webservice.get(greens_url,
562 ... headers={'If-None-Match': greens_etag}))562 ... headers={'If-None-Match': greens_etag}))
563 HTTP/1.1 304 Not Modified563 HTTP/1.1 304 Not Modified
564 ...564 Status: 304 Not Modified
565 Etag: "..."
566 X-Powered-By: Zope (www.zope.org), Python (www.python.org)
567 <BLANKLINE>
568 <BLANKLINE>
565569
566Conditional GET works the same way whether the request goes through570Conditional GET works the same way whether the request goes through
567the web service's virtual host or through the website-level interface571the web service's virtual host or through the website-level interface
@@ -574,7 +578,11 @@ designed for Ajax.
574 >>> etag = response.getheader("Etag")578 >>> etag = response.getheader("Etag")
575 >>> print(ajax.get(greens_url, headers={'If-None-Match' : etag}))579 >>> print(ajax.get(greens_url, headers={'If-None-Match' : etag}))
576 HTTP/1.1 304 Not Modified580 HTTP/1.1 304 Not Modified
577 ...581 Status: 304 Not Modified
582 Etag: "..."
583 X-Powered-By: Zope (www.zope.org), Python (www.python.org)
584 <BLANKLINE>
585 <BLANKLINE>
578586
579When you make a PUT or PATCH request, you can provide the ETag as the587When you make a PUT or PATCH request, you can provide the ETag as the
580If-Match header. This lets you detect changes that other people made588If-Match header. This lets you detect changes that other people made
@@ -741,7 +749,11 @@ the condition will fail if _any_ part of the ETag is different.
741 ... greens_url,749 ... greens_url,
742 ... headers={'If-None-Match': new_etag}))750 ... headers={'If-None-Match': new_etag}))
743 HTTP/1.1 304 Not Modified751 HTTP/1.1 304 Not Modified
744 ...752 Status: 304 Not Modified
753 Etag: "..."
754 X-Powered-By: Zope (www.zope.org), Python (www.python.org)
755 <BLANKLINE>
756 <BLANKLINE>
745757
746 >>> print(webservice.get(758 >>> print(webservice.get(
747 ... greens_url,759 ... greens_url,
diff --git a/src/lazr/restful/example/base/tests/field.txt b/src/lazr/restful/example/base/tests/field.txt
index ca6ec54..2bd775c 100644
--- a/src/lazr/restful/example/base/tests/field.txt
+++ b/src/lazr/restful/example/base/tests/field.txt
@@ -219,7 +219,11 @@ respond to conditional GET.
219219
220 >>> print(webservice.get(field_url, headers={'If-None-Match': etag}))220 >>> print(webservice.get(field_url, headers={'If-None-Match': etag}))
221 HTTP/1.1 304 Not Modified221 HTTP/1.1 304 Not Modified
222 ...222 Status: 304 Not Modified
223 Etag: "..."
224 X-Powered-By: Zope (www.zope.org), Python (www.python.org)
225 <BLANKLINE>
226 <BLANKLINE>
223227
224 >>> ignored = set_description("new description")228 >>> ignored = set_description("new description")
225 >>> print(webservice.get(field_url,229 >>> print(webservice.get(field_url,
diff --git a/src/lazr/restful/example/base/tests/hostedfile.txt b/src/lazr/restful/example/base/tests/hostedfile.txt
index 6cafe8d..3d60f69 100644
--- a/src/lazr/restful/example/base/tests/hostedfile.txt
+++ b/src/lazr/restful/example/base/tests/hostedfile.txt
@@ -82,7 +82,10 @@ Make a second request using the ETag, and you'll get the response code 304
82 >>> print(webservice.get(filemanager_url,82 >>> print(webservice.get(filemanager_url,
83 ... headers={'If-None-Match': etag}))83 ... headers={'If-None-Match': etag}))
84 HTTP/1.1 304 Not Modified84 HTTP/1.1 304 Not Modified
85 ...85 Status: 304 Not Modified
86 X-Powered-By: Zope (www.zope.org), Python (www.python.org)
87 <BLANKLINE>
88 <BLANKLINE>
8689
87PUT is also used to modify a hosted file. Here's one that provides a90PUT is also used to modify a hosted file. Here's one that provides a
88filename as part of Content-Disposition.91filename as part of Content-Disposition.
diff --git a/src/lazr/restful/example/base/tests/root.txt b/src/lazr/restful/example/base/tests/root.txt
index da8fede..f8a697a 100644
--- a/src/lazr/restful/example/base/tests/root.txt
+++ b/src/lazr/restful/example/base/tests/root.txt
@@ -78,9 +78,14 @@ current ETag, the representation won't be served again.
7878
79 >>> conditional_response = webservice.get(79 >>> conditional_response = webservice.get(
80 ... '/', headers={'If-None-Match' : etag})80 ... '/', headers={'If-None-Match' : etag})
81 >>> conditional_response.status81 >>> print(conditional_response)
82 30482 HTTP/1.1 304 Not Modified
83 >>> print(conditional_response.body.decode('UTF-8'))83 Status: 304 Not Modified
84 Cache-Control: max-age=2
85 Date: ...
86 Etag: "95e194e33248fa743d7066994043aa17b90c0989"
87 X-Powered-By: Zope (www.zope.org), Python (www.python.org)
88 <BLANKLINE>
84 <BLANKLINE>89 <BLANKLINE>
8590
86 >>> conditional_response = webservice.get(91 >>> conditional_response = webservice.get(
diff --git a/src/lazr/restful/publisher.py b/src/lazr/restful/publisher.py
index 2680999..e153ec0 100644
--- a/src/lazr/restful/publisher.py
+++ b/src/lazr/restful/publisher.py
@@ -219,7 +219,11 @@ class WebServicePublicationMixin:
219 def callObject(self, request, object):219 def callObject(self, request, object):
220 """Help web browsers handle redirects correctly."""220 """Help web browsers handle redirects correctly."""
221 value = super().callObject(request, object)221 value = super().callObject(request, object)
222 self._processNotifications(request)222 if request.response.getStatus() != 304:
223 # RFC 7232 section 4.1 says that 304 responses SHOULD NOT
224 # generate headers other than a short list of headers relevant
225 # to caching.
226 self._processNotifications(request)
223 if request.response.getStatus() // 100 == 3:227 if request.response.getStatus() // 100 == 3:
224 if IWebBrowserInitiatedRequest.providedBy(request):228 if IWebBrowserInitiatedRequest.providedBy(request):
225 # This request was (probably) sent by a web229 # This request was (probably) sent by a web
diff --git a/src/lazr/restful/testing/webservice.py b/src/lazr/restful/testing/webservice.py
index e3f8b7e..b80220c 100644
--- a/src/lazr/restful/testing/webservice.py
+++ b/src/lazr/restful/testing/webservice.py
@@ -37,7 +37,7 @@ import wsgi_intercept
37from zope.component import adapter, getGlobalSiteManager, getUtility37from zope.component import adapter, getGlobalSiteManager, getUtility
38from zope.configuration import xmlconfig38from zope.configuration import xmlconfig
39from zope.interface import alsoProvides, implementer, Interface39from zope.interface import alsoProvides, implementer, Interface
40from zope.publisher.interfaces.http import IHTTPApplicationRequest40from zope.publisher.interfaces.http import IHTTPApplicationRequest, IResult
41from zope.publisher.paste import Application41from zope.publisher.paste import Application
42from zope.proxy import ProxyBase42from zope.proxy import ProxyBase
43from zope.schema import TextLine43from zope.schema import TextLine
@@ -137,8 +137,10 @@ class FakeResponse:
137 if result is None:137 if result is None:
138 result = ""138 result = ""
139139
140 if not isinstance(result, str):140 if not isinstance(result, str) and not IResult.providedBy(result):
141 raise ValueError("only strings and None results are handled")141 raise ValueError(
142 "The result should be None, a string, or an IResult."
143 )
142144
143 self.result = result145 self.result = result
144146

Subscribers

People subscribed via source and target branches