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
1diff --git a/NEWS.rst b/NEWS.rst
2index e2a3c4c..bb1e578 100644
3--- a/NEWS.rst
4+++ b/NEWS.rst
5@@ -5,6 +5,8 @@ NEWS for lazr.restful
6 2.0.2
7 =====
8
9+- Don't send superfluous HTTP headers with 304 responses (bug 2039251).
10+
11 2.0.1 (2022-06-28)
12 ==================
13
14diff --git a/src/lazr/restful/_resource.py b/src/lazr/restful/_resource.py
15index d6b1919..6aceaa5 100644
16--- a/src/lazr/restful/_resource.py
17+++ b/src/lazr/restful/_resource.py
18@@ -65,7 +65,7 @@ from zope.location.interfaces import ILocation
19 from zope.pagetemplate.engine import TrustedAppPT
20 from zope.pagetemplate.pagetemplatefile import PageTemplateFile
21 from zope.proxy import isProxy
22-from zope.publisher.http import init_status_codes, status_reasons
23+from zope.publisher.http import DirectResult, init_status_codes, status_reasons
24 from zope.publisher.interfaces import NotFound
25 from zope.publisher.interfaces.http import IHTTPRequest
26 from zope.schema import ValidationError, getFieldsInOrder
27@@ -357,6 +357,24 @@ class HTTPResource:
28 if existing_etag in incoming_etags:
29 # The client already has this representation.
30 # No need to send it again.
31+ # RFC 7232 section 4.1 says that 304 responses SHOULD NOT
32+ # generate headers other than the following, unless they
33+ # exist for the purpose of guiding cache updates.
34+ keep_headers = {
35+ name: self.request.response.getHeader(name)
36+ for name in (
37+ "Cache-Control",
38+ "Content-Location",
39+ "Date",
40+ "ETag",
41+ "Expires",
42+ "Vary",
43+ )
44+ }
45+ self.request.response.reset()
46+ for name, value in sorted(keep_headers.items()):
47+ if value is not None:
48+ self.request.response.setHeader(name, value)
49 self.request.response.setStatus(304) # Not Modified
50 media_type = None
51 return media_type
52@@ -1341,7 +1359,7 @@ class EntryFieldResource(FieldUnmarshallerMixin, EntryManipulatingResource):
53 media_type = self.handleConditionalGET()
54 if media_type is None:
55 # The conditional GET succeeded. Serve nothing.
56- return b""
57+ return DirectResult(())
58 else:
59 self.request.response.setHeader("Content-Type", media_type)
60 return self._representation(media_type).encode()
61@@ -1630,7 +1648,7 @@ class EntryResource(
62 media_type = self.handleConditionalGET()
63 if media_type is None:
64 # The conditional GET succeeded. Serve nothing.
65- return b""
66+ return DirectResult(())
67 else:
68 self.request.response.setHeader("Content-Type", media_type)
69 return self._representation(media_type).encode()
70@@ -2011,7 +2029,7 @@ class ServiceRootResource(HTTPResource):
71 self.setCachingHeaders()
72 if media_type is None:
73 # The conditional GET succeeded. Serve nothing.
74- return b""
75+ return DirectResult(())
76 elif media_type in [self.WADL_TYPE, self.DEPRECATED_WADL_TYPE]:
77 result = self.toWADL()
78 elif media_type == self.JSON_TYPE:
79diff --git a/src/lazr/restful/basic-site.zcml b/src/lazr/restful/basic-site.zcml
80index 3e7eb2e..88e8901 100644
81--- a/src/lazr/restful/basic-site.zcml
82+++ b/src/lazr/restful/basic-site.zcml
83@@ -8,6 +8,7 @@
84 <include package="zope.component" file="meta.zcml"/>
85 <include package="zope.security" file="meta.zcml"/>
86 <include package="zope.component"/>
87+ <include package="zope.publisher"/>
88 <include package="zope.traversing"/>
89 <include package="grokcore.component" file="meta.zcml" />
90 <include package="lazr.enum"/>
91diff --git a/src/lazr/restful/example/base/filemanager.py b/src/lazr/restful/example/base/filemanager.py
92index 98c70ce..6d35a63 100644
93--- a/src/lazr/restful/example/base/filemanager.py
94+++ b/src/lazr/restful/example/base/filemanager.py
95@@ -9,6 +9,7 @@ import hashlib
96
97 import grokcore.component
98 from zope.interface import implementer
99+from zope.publisher.http import DirectResult
100
101 from lazr.restful import ReadOnlyResource
102 from lazr.restful.example.base.interfaces import IFileManager
103@@ -64,7 +65,7 @@ class ManagedFileResource(ReadOnlyResource):
104 incoming_etag = request.getHeader("If-None-Match")
105 if incoming_etag == self.etag:
106 response.setStatus(304)
107- return
108+ return DirectResult(())
109
110 response.setHeader("Content-Type", self.mediaType)
111 response.setHeader("Last-Modified", self.last_modified.isoformat())
112diff --git a/src/lazr/restful/example/base/tests/entry.txt b/src/lazr/restful/example/base/tests/entry.txt
113index 045bd14..e888ca7 100644
114--- a/src/lazr/restful/example/base/tests/entry.txt
115+++ b/src/lazr/restful/example/base/tests/entry.txt
116@@ -561,7 +561,11 @@ changed.
117 >>> print(webservice.get(greens_url,
118 ... headers={'If-None-Match': greens_etag}))
119 HTTP/1.1 304 Not Modified
120- ...
121+ Status: 304 Not Modified
122+ Etag: "..."
123+ X-Powered-By: Zope (www.zope.org), Python (www.python.org)
124+ <BLANKLINE>
125+ <BLANKLINE>
126
127 Conditional GET works the same way whether the request goes through
128 the web service's virtual host or through the website-level interface
129@@ -574,7 +578,11 @@ designed for Ajax.
130 >>> etag = response.getheader("Etag")
131 >>> print(ajax.get(greens_url, headers={'If-None-Match' : etag}))
132 HTTP/1.1 304 Not Modified
133- ...
134+ Status: 304 Not Modified
135+ Etag: "..."
136+ X-Powered-By: Zope (www.zope.org), Python (www.python.org)
137+ <BLANKLINE>
138+ <BLANKLINE>
139
140 When you make a PUT or PATCH request, you can provide the ETag as the
141 If-Match header. This lets you detect changes that other people made
142@@ -741,7 +749,11 @@ the condition will fail if _any_ part of the ETag is different.
143 ... greens_url,
144 ... headers={'If-None-Match': new_etag}))
145 HTTP/1.1 304 Not Modified
146- ...
147+ Status: 304 Not Modified
148+ Etag: "..."
149+ X-Powered-By: Zope (www.zope.org), Python (www.python.org)
150+ <BLANKLINE>
151+ <BLANKLINE>
152
153 >>> print(webservice.get(
154 ... greens_url,
155diff --git a/src/lazr/restful/example/base/tests/field.txt b/src/lazr/restful/example/base/tests/field.txt
156index ca6ec54..2bd775c 100644
157--- a/src/lazr/restful/example/base/tests/field.txt
158+++ b/src/lazr/restful/example/base/tests/field.txt
159@@ -219,7 +219,11 @@ respond to conditional GET.
160
161 >>> print(webservice.get(field_url, headers={'If-None-Match': etag}))
162 HTTP/1.1 304 Not Modified
163- ...
164+ Status: 304 Not Modified
165+ Etag: "..."
166+ X-Powered-By: Zope (www.zope.org), Python (www.python.org)
167+ <BLANKLINE>
168+ <BLANKLINE>
169
170 >>> ignored = set_description("new description")
171 >>> print(webservice.get(field_url,
172diff --git a/src/lazr/restful/example/base/tests/hostedfile.txt b/src/lazr/restful/example/base/tests/hostedfile.txt
173index 6cafe8d..3d60f69 100644
174--- a/src/lazr/restful/example/base/tests/hostedfile.txt
175+++ b/src/lazr/restful/example/base/tests/hostedfile.txt
176@@ -82,7 +82,10 @@ Make a second request using the ETag, and you'll get the response code 304
177 >>> print(webservice.get(filemanager_url,
178 ... headers={'If-None-Match': etag}))
179 HTTP/1.1 304 Not Modified
180- ...
181+ Status: 304 Not Modified
182+ X-Powered-By: Zope (www.zope.org), Python (www.python.org)
183+ <BLANKLINE>
184+ <BLANKLINE>
185
186 PUT is also used to modify a hosted file. Here's one that provides a
187 filename as part of Content-Disposition.
188diff --git a/src/lazr/restful/example/base/tests/root.txt b/src/lazr/restful/example/base/tests/root.txt
189index da8fede..f8a697a 100644
190--- a/src/lazr/restful/example/base/tests/root.txt
191+++ b/src/lazr/restful/example/base/tests/root.txt
192@@ -78,9 +78,14 @@ current ETag, the representation won't be served again.
193
194 >>> conditional_response = webservice.get(
195 ... '/', headers={'If-None-Match' : etag})
196- >>> conditional_response.status
197- 304
198- >>> print(conditional_response.body.decode('UTF-8'))
199+ >>> print(conditional_response)
200+ HTTP/1.1 304 Not Modified
201+ Status: 304 Not Modified
202+ Cache-Control: max-age=2
203+ Date: ...
204+ Etag: "95e194e33248fa743d7066994043aa17b90c0989"
205+ X-Powered-By: Zope (www.zope.org), Python (www.python.org)
206+ <BLANKLINE>
207 <BLANKLINE>
208
209 >>> conditional_response = webservice.get(
210diff --git a/src/lazr/restful/publisher.py b/src/lazr/restful/publisher.py
211index 2680999..e153ec0 100644
212--- a/src/lazr/restful/publisher.py
213+++ b/src/lazr/restful/publisher.py
214@@ -219,7 +219,11 @@ class WebServicePublicationMixin:
215 def callObject(self, request, object):
216 """Help web browsers handle redirects correctly."""
217 value = super().callObject(request, object)
218- self._processNotifications(request)
219+ if request.response.getStatus() != 304:
220+ # RFC 7232 section 4.1 says that 304 responses SHOULD NOT
221+ # generate headers other than a short list of headers relevant
222+ # to caching.
223+ self._processNotifications(request)
224 if request.response.getStatus() // 100 == 3:
225 if IWebBrowserInitiatedRequest.providedBy(request):
226 # This request was (probably) sent by a web
227diff --git a/src/lazr/restful/testing/webservice.py b/src/lazr/restful/testing/webservice.py
228index e3f8b7e..b80220c 100644
229--- a/src/lazr/restful/testing/webservice.py
230+++ b/src/lazr/restful/testing/webservice.py
231@@ -37,7 +37,7 @@ import wsgi_intercept
232 from zope.component import adapter, getGlobalSiteManager, getUtility
233 from zope.configuration import xmlconfig
234 from zope.interface import alsoProvides, implementer, Interface
235-from zope.publisher.interfaces.http import IHTTPApplicationRequest
236+from zope.publisher.interfaces.http import IHTTPApplicationRequest, IResult
237 from zope.publisher.paste import Application
238 from zope.proxy import ProxyBase
239 from zope.schema import TextLine
240@@ -137,8 +137,10 @@ class FakeResponse:
241 if result is None:
242 result = ""
243
244- if not isinstance(result, str):
245- raise ValueError("only strings and None results are handled")
246+ if not isinstance(result, str) and not IResult.providedBy(result):
247+ raise ValueError(
248+ "The result should be None, a string, or an IResult."
249+ )
250
251 self.result = result
252

Subscribers

People subscribed via source and target branches