Fix VIM REST API missing end_headers() causing empty responses

The VIM compute REST API error paths (404, 400, 204) call
send_response() without end_headers(). In python3, response headers
are only appended to an internal buffer; end_headers() is what
flushes them to the socket. The dispatcher's done() method, which
used to be the finaliser, was reduced to a no-op in [1]. The net
effect is that zero bytes are written before the connection is
closed: nova-api-proxy's httplib raises RemoteDisconnected, and
eventlet synthesises an empty HTTP 500 for the client where nova
itself would have returned a 404 with a JSON body.

The same defect affects every handler path that omits an explicit
end_headers(), including the delayed-response paths in
_action_request_complete() that pass no headers, so the
max-concurrent 503 and the ageout auto-accept 202 also send nothing.
On the delayed path finish() early-returns and done() was the only
remaining cleanup, so those requests have also been leaking sockets
since [1].

Restore RestAPIRequestDispatcher.done() as the single point of
response finalisation:

- Override end_headers() and guard it with a new _headers_ended
  flag, so handlers that already call it explicitly are unaffected
  and a second call cannot append a stray CRLF into the body.
- done() always calls end_headers(), and additionally calls _done()
  when the response was delayed, which is the only opportunity to
  release that socket.

Also in the compute plugin, add the done() call that the invalid-URL
early return was missing, and return nova-compatible JSON error
bodies for 400 and 404 so that a client sees the same shape whether
the action was intercepted by the proxy or passed through to nova:

  {"itemNotFound": {"code": 404, "message": "..."}}

The 404 message text is aligned with nova's byte for byte, including
its trailing period. Writing an error body puts a socket write on
paths that previously wrote nothing, so the write is guarded against
a client that has already disconnected; without the guard a dropped
client would produce the kind of IOError traceback that [1] set out
to remove.

Relatedly, _action_request_complete() flushed the headers only when
the relayed response carried headers of its own, and wrote the body
afterwards. Now that done() flushes, a caller passing a body but no
headers would have put that body on the wire ahead of the header
block. The flush is now unconditional and precedes the body. No
current caller passes a body without headers, but the ordering is no
longer left to chance.

Finally, WritableLogger.write() hardcoded a DEBUG call while
__init__ accepted a level argument it never used, which hid the
eventlet WSGI traceback behind the proxy's default INFO level.
write() now logs at the configured level.

Not changed:

- Socket teardown on the non-delayed path stays in finish(). wfile
  must remain open until http.server's handle_one_request() has
  completed its own wfile.flush(), which is the constraint [1] was
  addressing; done() only adds the header flush there.
- No safety-net end_headers() was added to finish(). done() is the
  load-bearing mechanism and every handler calls it. On the only
  path that could reach finish() with unflushed state - a request
  that matched no handler, so no status line was ever buffered -
  end_headers() would emit a bare CRLF, turning a clean zero-byte
  close into a malformed response.
- The 204 path stays bodyless, as a 204 must not carry a body.
- The HTTP/1.0 status line is unchanged. RestAPIServer assigns the
  handler's 'protocol' attribute while http.server reads
  'protocol_version'; that mismatch has keep-alive implications and
  is a separate concern.
- VIM instance lookup semantics are unchanged. A UUID absent from
  the VIM table still gets a 404 rather than being passed through to
  nova; making nova authoritative for unknown instances is a
  separate change.

References:
[1] bb99182 "Debian: Eliminate nfv IOError logs during api calls"
[2] 1e475dc "Debian: Fix nova actions", which fixed only the
    relayed-response path
[3] https://docs.python.org/3/library/http.server.html
    BaseHTTPRequestHandler.end_headers

Test Plan:
  AIO-SX, stx-openstack applied:
    PASS - POST {"pause": {}} for an unknown server uuid through
           nova-api-proxy returns 404 with Content-Type
           application/json and a 111 byte itemNotFound body; before
           the change the same request returned 500 with
           Content-Length 0 and an empty body
    PASS - differential control: the same request with a "lock"
           action, which the proxy does not intercept and therefore
           routes to nova-api, returns a byte-identical response
           body
    PASS - raw socket to the VIM compute REST API port returns 227
           bytes for the 404 path, 207 for the invalid-url 400, 187
           for the no-action 400 and 64 for the bodyless 204; every
           one of those returned 0 bytes and a clean FIN before the
           change
    PASS - nova-api-proxy now logs the relayed response body for the
           intercepted request, which previously produced no log
           line at all
    SKIP** - max-concurrent 503 on the delayed-response path
    SKIP** - ageout auto-accept 202 on the delayed-response path
    SKIP** - pause/unpause of an existing instance
  Unit tests:
    PASS - tox -c nfv/tox.ini -e py39 -- test_nfvi_compute_rest_api
           (15 new tests, covering the 404, 400, 204 and invalid-url
           paths, the delayed 503 and 202 paths, relay of a nova
           response, end_headers() idempotency, that done() releases
           the socket only on the delayed path, that a client
           disconnect mid-response neither raises nor leaks the
           request, and that a relayed body never precedes the
           header block)
  Regression:
    PASS - GET on the VIM infrastructure REST API, a handler that
           already called end_headers(), returns the same 154 bytes
           with exactly one header terminator
    PASS - no dispatcher errors in nfv-vim.log after the change; the
           only tracebacks present are pre-existing and appear in
           equal numbers before it
    PASS - tox -e linters
    PASS - tox -c nfv/tox.ini -e pep8
    PASS - tox -c nfv/tox.ini -e pylint (10.00/10)
    PASS - tox -c nfv/tox.ini -e black
    PASS - tox -c nfv/tox.ini -e py39 (511 tests, 510 passed,
           1 pre-existing skip)
    PASS - tox -c nfv/tox.ini -e cover
    PASS - tox -c nova-api-proxy/tox.ini -e pep8
    PASS - tox -c nova-api-proxy/tox.ini -e pylint (10.00/10)
    PASS - tox -c nova-api-proxy/tox.ini -e black

  ** The delayed-response paths require the instance to be present
     in the VIM instance table, and pause/unpause requires a running
     instance. The test system had no working nova-compute, so these
     three items were exercised by the new unit tests only.

Closes-Bug: 2163671
Change-Id: I6e070ed1c33bb88349ee4501679c7ea1b3690b90
Signed-off-by: Reese Norris <reese.norris@windriver.com>
This commit is contained in:
Reese Norris committed 2026-08-18 13:55:17 -04:00
1 parent e4df194656
commit 69f526fe35
4 files changed
+521 -8

No files matched your search

@@ -26,6 +26,43 @@ from nfv_vim.nfvi.objects import v1 as nfvi_objs
DLOG = debug.debug_get_logger("nfv_plugins.nfvi_plugins.compute_api")
# The nova api names its error bodies after the http status code, see
# nova.api.openstack.wsgi.Fault._fault_names. Only the codes that this
# plugin can produce are listed here.
NOVA_FAULT_NAMES = {
httplib.BAD_REQUEST: "badRequest",
httplib.NOT_FOUND: "itemNotFound",
}
def nova_error_body(http_status_code, message):
"""Build a nova compatible JSON error response body, encoded as bytes."""
fault_name = NOVA_FAULT_NAMES.get(http_status_code, "computeFault")
body = {fault_name: {"code": http_status_code, "message": message}}
return json.dumps(body).encode()
def send_error_response(request_dispatch, http_status_code, message):
"""Send a nova compatible JSON error response and finish the request."""
http_body = nova_error_body(http_status_code, message)
# The reason phrase is left at the standard one for the status code, as
# nova does; the detail belongs in the response body.
request_dispatch.send_response(http_status_code)
request_dispatch.send_header("Content-Type", "application/json")
request_dispatch.send_header("Content-Length", str(len(http_body)))
try:
request_dispatch.end_headers()
request_dispatch.wfile.write(http_body)
except socket.error as e:
# The client can disconnect while waiting for the response.
DLOG.error("Send response for %s failed, error=%s" % (request_dispatch.path, e))
# Called even if the send failed, as the delayed path releases the socket
# from here.
request_dispatch.done()
def hypervisor_get_admin_state(status):
"""Convert the nfvi hypervisor status to a hypervisor administrative state."""
@@ -842,7 +879,9 @@ class NFVIComputeAPI(nfvi.api.v1.NFVIComputeAPI):
for key, value in http_headers:
if "Server" != key and "Date" != key:
request_dispatch.send_header(key, value)
request_dispatch.end_headers()
# Flush the headers before any body is written, whether or
# not the relayed response carried headers of its own.
request_dispatch.end_headers()
if http_body is not None:
if hasattr(http_body, "encode"):
http_body = http_body.encode()
@@ -3565,7 +3604,11 @@ class NFVIComputeAPI(nfvi.api.v1.NFVIComputeAPI):
content = request_dispatch.rfile.read(content_len)
if "action" != request_dispatch.path.split("/")[-1]:
DLOG.error("Invalid url %s received" % request_dispatch.path)
request_dispatch.send_response(httplib.BAD_REQUEST)
send_error_response(
request_dispatch,
httplib.BAD_REQUEST,
"Invalid url, expecting a server action request",
)
return
path_items = request_dispatch.path.split("/")
@@ -3620,9 +3663,10 @@ class NFVIComputeAPI(nfvi.api.v1.NFVIComputeAPI):
DLOG.error(
"Callback failed for instance_uuid=%s." % instance_uuid
)
err_msg = "Instance %s could not be found" % instance_uuid
err_msg = "Instance %s could not be found." % instance_uuid
http_response = httplib.NOT_FOUND
else:
err_msg = "No server action specified"
http_response = httplib.BAD_REQUEST
else:
http_response = httplib.NO_CONTENT
@@ -3639,8 +3683,15 @@ class NFVIComputeAPI(nfvi.api.v1.NFVIComputeAPI):
request_dispatch.response_delayed()
self._ageout_action_requests()
else:
request_dispatch.send_response(http_response, message=err_msg)
request_dispatch.done()
if httplib.NO_CONTENT == http_response:
# A 204 response must not carry a message body. done()
# flushes the buffered headers, so no explicit
# end_headers() is needed here, unlike the error paths
# that go through send_error_response().
request_dispatch.send_response(http_response)
request_dispatch.done()
else:
send_error_response(request_dispatch, http_response, err_msg)
self._request_times.pop()
del self._requests[request_uuid]
@@ -41,6 +41,7 @@ class RestAPIRequestDispatcher(BaseHTTPServer.BaseHTTPRequestHandler):
def __init__(self, request, client_address, server):
self._is_shutdown = False
self._response_delayed = False
self._headers_ended = False
# Call old-style class __init__
BaseHTTPServer.BaseHTTPRequestHandler.__init__(
@@ -59,6 +60,13 @@ class RestAPIRequestDispatcher(BaseHTTPServer.BaseHTTPRequestHandler):
if "server" != keyword.lower():
BaseHTTPServer.BaseHTTPRequestHandler.send_header(self, keyword, value)
def end_headers(self):
"""Override end_headers so that it can safely be called more than once."""
if not self._is_shutdown and not self._headers_ended:
self._headers_ended = True
BaseHTTPServer.BaseHTTPRequestHandler.end_headers(self)
def send_response(self, code, message=None):
"""Override send_response."""
@@ -78,8 +86,22 @@ class RestAPIRequestDispatcher(BaseHTTPServer.BaseHTTPRequestHandler):
DLOG.error(format, *args)
def done(self):
# todo:abailey Remove the calls to 'done' in the plugins
DLOG.debug("Deprecated: 'done' method no longer supported")
"""Finalize the response.
Flushes the buffered headers to the socket, and on the delayed
response path also releases the socket, since finish() has already
returned without doing so.
"""
if not self._is_shutdown:
# In python3 send_response and send_header only buffer the
# headers, end_headers is what writes them to the socket.
self.end_headers()
if self._response_delayed:
# finish has already returned without cleaning up, so this
# is the only remaining opportunity to release the socket.
self._done()
def _done(self):
"""Finished with processing the request."""
@@ -0,0 +1,440 @@
#
# Copyright (c) 2026 Wind River Systems, Inc.
#
# SPDX-License-Identifier: Apache-2.0
#
import http.client as httplib
import json
import sys
from unittest import mock
from nfv_common import timers
from nfv_unit_tests.tests import testcase
sys.modules["fm_core"] = mock.Mock()
# pylint: disable-next=C0413
from nfv_plugins.nfvi_plugins.nfvi_compute_api import ( # noqa: H306,E402
NFVIComputeAPI,
)
# pylint: disable-next=C0413
from nfv_plugins.nfvi_plugins.openstack.rest_api import ( # noqa: H306,E402
RestAPIRequestDispatcher,
)
INSTANCE_UUID = "deadbeef-dead-beef-dead-beefdeadbeef"
TENANT_UUID = "11111111111111111111111111111111"
ACTION_PATH = "/v2.1/%s/servers/%s/action" % (TENANT_UUID, INSTANCE_UUID)
# The status line is emitted as HTTP/1.0 because RestAPIServer assigns the
# handler's 'protocol' attribute while http.server reads 'protocol_version'.
# That pre-existing mismatch is out of scope here, so the tests assert the
# version that is actually produced.
HTTP_VERSION = "HTTP/1.0"
class FakeSocketFile:
"""A wfile/rfile stand-in that retains everything written to it.
io.BytesIO discards its buffer on close, and the delayed-response path
closes wfile, so the bytes on the wire have to be recorded separately.
"""
def __init__(self, data=b"", raise_on_write=False):
self.data = data
self.written = b""
self.closed = False
self.raise_on_write = raise_on_write
def read(self, amount=None):
if amount is None:
amount = len(self.data)
result = self.data[:amount]
self.data = self.data[amount:]
return result
def write(self, data):
if self.raise_on_write:
raise BrokenPipeError("connection reset by peer")
self.written += data
return len(data)
def writelines(self, lines):
for line in lines:
self.write(line)
def flush(self):
pass
def close(self):
self.closed = True
def make_dispatcher(path=ACTION_PATH, body=b""):
"""Build a dispatcher without running the socket-bound lifecycle.
BaseHTTPRequestHandler.__init__ reads and dispatches a request as a side
effect of construction, so the object is built directly and given only
the attributes the response path touches.
"""
dispatcher = object.__new__(RestAPIRequestDispatcher)
dispatcher._is_shutdown = False
dispatcher._response_delayed = False
dispatcher._headers_ended = False
dispatcher.path = path
dispatcher.command = "POST"
dispatcher.request_version = "HTTP/1.1"
dispatcher.requestline = "POST %s HTTP/1.1" % path
dispatcher.client_address = ("127.0.0.1", 45678)
dispatcher.headers = {"content-length": str(len(body))}
dispatcher.rfile = FakeSocketFile(body)
dispatcher.wfile = FakeSocketFile()
dispatcher.request = mock.Mock()
return dispatcher
class TestNFVIComputeRestAPI(testcase.NFVTestCase):
"""Verify that the compute rest-api error paths put bytes on the wire."""
def setUp(self):
super().setUp()
# send_response logs the request line to stderr via log_message.
patcher = mock.patch.object(RestAPIRequestDispatcher, "log_request")
patcher.start()
self.addCleanup(patcher.stop)
self.plugin_api = NFVIComputeAPI()
@staticmethod
def status_line(http_status_code):
"""Build the status line the dispatcher is expected to emit."""
return (
"%s %d %s\r\n"
% (HTTP_VERSION, http_status_code, httplib.responses[http_status_code])
).encode()
def assert_single_header_terminator(self, dispatcher):
"""Assert the header block was terminated exactly once."""
self.assertEqual(1, dispatcher.wfile.written.count(b"\r\n\r\n"))
def assert_json_error_body(self, dispatcher, fault_name, http_status_code, message):
"""Assert a nova compatible JSON error body was written."""
header_block, _, http_body = dispatcher.wfile.written.partition(b"\r\n\r\n")
self.assertIn(b"Content-Type: application/json", header_block)
self.assertIn(
("Content-Length: %d" % len(http_body)).encode(),
header_block,
)
self.assertEqual(
{fault_name: {"code": http_status_code, "message": message}},
json.loads(http_body),
)
# -----------------------------------------------------------------------
# Non-delayed error paths.
# -----------------------------------------------------------------------
def test_instance_action_not_found_writes_response(self):
"""A 404 for an unknown instance reaches the wire with a json body."""
self.plugin_api.register_instance_action_callback(
lambda instance_uuid, action_data: False
)
dispatcher = make_dispatcher(body=b'{"pause": {}}')
self.plugin_api.instance_action_rest_api_post_handler(dispatcher)
err_msg = "Instance %s could not be found." % INSTANCE_UUID
self.assertTrue(
dispatcher.wfile.written.startswith(self.status_line(httplib.NOT_FOUND))
)
self.assert_single_header_terminator(dispatcher)
self.assert_json_error_body(
dispatcher, "itemNotFound", httplib.NOT_FOUND, err_msg
)
# The bookkeeping is released and the socket teardown is left to
# finish(), which the caller of the handler has not reached yet.
self.assertEqual({}, self.plugin_api._requests)
self.assertEqual(0, len(self.plugin_api._request_times))
self.assertFalse(dispatcher.wfile.closed)
def test_instance_action_no_action_writes_response(self):
"""A body carrying no action produces a 400 on the wire."""
dispatcher = make_dispatcher(body=b"{}")
self.plugin_api.instance_action_rest_api_post_handler(dispatcher)
err_msg = "No server action specified"
self.assertTrue(
dispatcher.wfile.written.startswith(self.status_line(httplib.BAD_REQUEST))
)
self.assert_single_header_terminator(dispatcher)
self.assert_json_error_body(
dispatcher, "badRequest", httplib.BAD_REQUEST, err_msg
)
def test_instance_action_empty_body_writes_response(self):
"""An empty request body produces a bodyless 204 on the wire."""
dispatcher = make_dispatcher(body=b"")
self.plugin_api.instance_action_rest_api_post_handler(dispatcher)
self.assertTrue(
dispatcher.wfile.written.startswith(self.status_line(httplib.NO_CONTENT))
)
self.assert_single_header_terminator(dispatcher)
# A 204 must not carry a message body.
self.assertTrue(dispatcher.wfile.written.endswith(b"\r\n\r\n"))
self.assertNotIn(b"Content-Type", dispatcher.wfile.written)
def test_instance_action_invalid_url_writes_response(self):
"""The invalid-url early return produces a 400 on the wire."""
dispatcher = make_dispatcher(
path="/v2.1/%s/servers/%s" % (TENANT_UUID, INSTANCE_UUID),
body=b'{"pause": {}}',
)
self.plugin_api.instance_action_rest_api_post_handler(dispatcher)
err_msg = "Invalid url, expecting a server action request"
self.assertTrue(
dispatcher.wfile.written.startswith(self.status_line(httplib.BAD_REQUEST))
)
self.assert_single_header_terminator(dispatcher)
self.assert_json_error_body(
dispatcher, "badRequest", httplib.BAD_REQUEST, err_msg
)
# The early return happens before any request bookkeeping.
self.assertEqual({}, self.plugin_api._requests)
def test_instance_action_client_disconnect_is_not_fatal(self):
"""A client that disconnects mid-response does not raise."""
self.plugin_api.register_instance_action_callback(
lambda instance_uuid, action_data: False
)
dispatcher = make_dispatcher(body=b'{"pause": {}}')
dispatcher.wfile = FakeSocketFile(raise_on_write=True)
# The write failure is logged and swallowed rather than escaping into
# http.server, which would log a traceback for every dropped client.
self.plugin_api.instance_action_rest_api_post_handler(dispatcher)
self.assertEqual(b"", dispatcher.wfile.written)
# The bookkeeping is still released, so the request does not leak.
self.assertEqual({}, self.plugin_api._requests)
self.assertEqual(0, len(self.plugin_api._request_times))
# -----------------------------------------------------------------------
# Delayed-response paths. These cannot be driven from a live system
# unless an instance is known to the vim, so they are only covered here.
# -----------------------------------------------------------------------
def _register_delayed_request(self, request_uuid, age_in_ms=0):
"""Register a request that is awaiting a delayed response."""
dispatcher = make_dispatcher(body=b'{"pause": {}}')
dispatcher.response_delayed()
self.plugin_api._requests[request_uuid] = dispatcher
self.plugin_api._request_times.append(
(request_uuid, timers.get_monotonic_timestamp_in_ms() - age_in_ms)
)
return dispatcher
def test_max_concurrent_requests_writes_response(self):
"""The max-concurrent 503 reaches the wire and releases the socket."""
dispatcher = self._register_delayed_request("request-503")
self.plugin_api._max_concurrent_action_requests = 0
self.plugin_api._ageout_action_requests()
self.assertTrue(
dispatcher.wfile.written.startswith(
self.status_line(httplib.SERVICE_UNAVAILABLE)
)
)
self.assert_single_header_terminator(dispatcher)
self.assertEqual({}, self.plugin_api._requests)
# On the delayed path finish() has already returned without cleaning
# up, so done() has to release the socket itself.
self.assertTrue(dispatcher.wfile.closed)
self.assertTrue(dispatcher._is_shutdown)
def test_ageout_auto_accept_writes_response(self):
"""The ageout 202 reaches the wire and releases the socket."""
wait_in_ms = self.plugin_api._max_action_request_wait_in_secs * 1000
dispatcher = self._register_delayed_request(
"request-202", age_in_ms=wait_in_ms + 1000
)
self.plugin_api._ageout_action_requests()
self.assertTrue(
dispatcher.wfile.written.startswith(self.status_line(httplib.ACCEPTED))
)
self.assert_single_header_terminator(dispatcher)
self.assertEqual({}, self.plugin_api._requests)
self.assertTrue(dispatcher.wfile.closed)
self.assertTrue(dispatcher._is_shutdown)
def test_action_request_complete_relays_nova_response(self):
"""A relayed nova response is not corrupted by the added end_headers."""
dispatcher = self._register_delayed_request("request-200")
http_body = b'{"server": {}}'
self.plugin_api._action_request_complete(
"request-200",
httplib.OK,
http_headers=[
("Server", "suppressed"),
("Date", "suppressed"),
("Content-Type", "application/json"),
("Content-Length", str(len(http_body))),
],
http_body=http_body,
)
header_block, _, written_body = dispatcher.wfile.written.partition(b"\r\n\r\n")
self.assert_single_header_terminator(dispatcher)
self.assertEqual(http_body, written_body)
self.assertIn(b"Content-Type: application/json", header_block)
self.assertNotIn(b"Server: suppressed", header_block)
self.assertTrue(dispatcher.wfile.closed)
def test_action_request_complete_writes_headers_before_body(self):
"""A body is never written ahead of the header block.
A relayed response can carry a body without headers, and the header
flush moved into done(), so the flush has to happen before the body
rather than after it.
"""
dispatcher = self._register_delayed_request("request-500")
http_body = b'{"computeFault": {}}'
self.plugin_api._action_request_complete(
"request-500",
httplib.INTERNAL_SERVER_ERROR,
http_headers=None,
http_body=http_body,
)
header_block, _, written_body = dispatcher.wfile.written.partition(b"\r\n\r\n")
self.assert_single_header_terminator(dispatcher)
self.assertTrue(
header_block.startswith(
self.status_line(httplib.INTERNAL_SERVER_ERROR).rstrip(b"\r\n")
)
)
self.assertEqual(http_body, written_body)
class TestRestAPIRequestDispatcher(testcase.NFVTestCase):
"""Verify the dispatcher-level response finalisation."""
def setUp(self):
super().setUp()
patcher = mock.patch.object(RestAPIRequestDispatcher, "log_request")
patcher.start()
self.addCleanup(patcher.stop)
def test_done_flushes_buffered_headers(self):
"""done() writes the headers that send_response only buffered."""
dispatcher = make_dispatcher()
dispatcher.send_response(httplib.NOT_FOUND)
# In python3 send_response only appends to the header buffer.
self.assertEqual(b"", dispatcher.wfile.written)
dispatcher.done()
self.assertTrue(dispatcher.wfile.written.startswith(b"HTTP/1.0 404 "))
self.assertTrue(dispatcher.wfile.written.endswith(b"\r\n\r\n"))
def test_done_does_not_close_socket_when_not_delayed(self):
"""done() leaves the socket to finish() on the non-delayed path."""
dispatcher = make_dispatcher()
dispatcher.send_response(httplib.NOT_FOUND)
dispatcher.done()
self.assertFalse(dispatcher.wfile.closed)
self.assertFalse(dispatcher._is_shutdown)
dispatcher.request.shutdown.assert_not_called()
# finish() is what tears the socket down, once http.server is done
# with wfile.
dispatcher.finish()
self.assertTrue(dispatcher.wfile.closed)
self.assertTrue(dispatcher._is_shutdown)
def test_done_closes_socket_when_delayed(self):
"""done() releases the socket on the delayed path, where finish() will not."""
dispatcher = make_dispatcher()
dispatcher.response_delayed()
dispatcher.send_response(httplib.SERVICE_UNAVAILABLE)
dispatcher.done()
self.assertTrue(dispatcher.wfile.written.startswith(b"HTTP/1.0 503 "))
self.assertTrue(dispatcher.wfile.closed)
self.assertTrue(dispatcher._is_shutdown)
# finish() early-returns on this path and must not double up.
dispatcher.finish()
self.assertEqual(1, dispatcher.request.shutdown.call_count)
def test_end_headers_is_idempotent(self):
"""An explicit end_headers() followed by done() does not corrupt the body."""
dispatcher = make_dispatcher()
http_body = b'{"itemNotFound": {}}'
dispatcher.send_response(httplib.NOT_FOUND)
dispatcher.send_header("Content-Type", "application/json")
dispatcher.send_header("Content-Length", str(len(http_body)))
dispatcher.end_headers()
dispatcher.wfile.write(http_body)
dispatcher.done()
self.assertEqual(1, dispatcher.wfile.written.count(b"\r\n\r\n"))
self.assertTrue(dispatcher.wfile.written.endswith(http_body))
def test_end_headers_after_shutdown_writes_nothing(self):
"""end_headers() is inert once the socket has been released."""
dispatcher = make_dispatcher()
dispatcher.response_delayed()
dispatcher.send_response(httplib.ACCEPTED)
dispatcher.done()
written = dispatcher.wfile.written
dispatcher.end_headers()
dispatcher.done()
self.assertEqual(written, dispatcher.wfile.written)
def test_send_header_suppresses_server_header(self):
"""The Server header is still suppressed through the new flush path."""
dispatcher = make_dispatcher()
dispatcher.send_response(httplib.OK)
dispatcher.send_header("Server", "should-not-appear")
dispatcher.done()
self.assertNotIn(b"should-not-appear", dispatcher.wfile.written)
self.assertIn(b"Date: ", dispatcher.wfile.written)
@@ -93,7 +93,7 @@ class WritableLogger:
self.level = level
def write(self, msg):
self.logger.debug(msg.rstrip())
self.logger.log(self.level, msg.rstrip())
class Server: