This is an automated email from the ASF dual-hosted git repository.

bneradt pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/trafficserver.git


The following commit(s) were added to refs/heads/master by this push:
     new 2c9cce64bb Add autest for http2.incomplete_header_timeout_in (#13433)
2c9cce64bb is described below

commit 2c9cce64bb04987b0492de4710f923b5e07ed3ce
Author: Brian Neradt <[email protected]>
AuthorDate: Wed Jul 29 11:27:09 2026 -0500

    Add autest for http2.incomplete_header_timeout_in (#13433)
    
    No off-the-shelf client can withhold the END_HEADERS flag, so this adds
    an ad-hoc HTTP/2 client that fragments the request header block and
    optionally never sends the CONTINUATION frame. The test also covers a
    CONTINUATION that arrives in time and a slow origin response, so that a
    regression in canceling the timeout at transaction start is caught too.
    
    Fixes: #11391
---
 .../timeout/http2_incomplete_header_client.py      | 290 +++++++++++++++++++++
 .../http2_incomplete_header_timeout.test.py        | 207 +++++++++++++++
 .../http2_incomplete_header_timeout.replay.yaml    |  63 +++++
 3 files changed, 560 insertions(+)

diff --git a/tests/gold_tests/timeout/http2_incomplete_header_client.py 
b/tests/gold_tests/timeout/http2_incomplete_header_client.py
new file mode 100644
index 0000000000..d1616fef2e
--- /dev/null
+++ b/tests/gold_tests/timeout/http2_incomplete_header_client.py
@@ -0,0 +1,290 @@
+#!/usr/bin/env python3
+'''
+HTTP/2 client that fragments a request header block across a HEADERS frame and
+an optional CONTINUATION frame in order to exercise
+proxy.config.http2.incomplete_header_timeout_in.
+'''
+#  Licensed to the Apache Software Foundation (ASF) under one
+#  or more contributor license agreements.  See the NOTICE file
+#  distributed with this work for additional information
+#  regarding copyright ownership.  The ASF licenses this file
+#  to you under the Apache License, Version 2.0 (the
+#  "License"); you may not use this file except in compliance
+#  with the License.  You may obtain a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+#  Unless required by applicable law or agreed to in writing, software
+#  distributed under the License is distributed on an "AS IS" BASIS,
+#  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+#  See the License for the specific language governing permissions and
+#  limitations under the License.
+
+import argparse
+import socket
+import ssl
+import sys
+import time
+from typing import List, Optional, Tuple
+
+import hpack
+
+CONNECTION_PREFACE = b'PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n'
+
+FRAME_TYPE_DATA = 0x00
+FRAME_TYPE_HEADERS = 0x01
+FRAME_TYPE_RST_STREAM = 0x03
+FRAME_TYPE_SETTINGS = 0x04
+FRAME_TYPE_GOAWAY = 0x07
+FRAME_TYPE_CONTINUATION = 0x09
+
+FLAG_ACK = 0x01
+FLAG_END_STREAM = 0x01
+FLAG_END_HEADERS = 0x04
+
+STREAM_ID = 1
+
+
+def make_socket(port: int, socket_timeout: float) -> ssl.SSLSocket:
+    '''Establish a TLS connection to ATS with h2 negotiated via ALPN.
+
+    :param port: The ATS TLS port to connect to.
+    :param socket_timeout: The socket timeout, in seconds.
+    :return: The connected TLS socket.
+    '''
+    ctx = ssl.create_default_context()
+    ctx.check_hostname = False
+    ctx.verify_mode = ssl.CERT_NONE
+    ctx.set_alpn_protocols(['h2'])
+
+    raw = socket.create_connection(('127.0.0.1', port), timeout=socket_timeout)
+    tls = ctx.wrap_socket(raw, server_hostname='localhost')
+    if tls.selected_alpn_protocol() != 'h2':
+        raise RuntimeError(f'failed to negotiate h2, got 
{tls.selected_alpn_protocol()!r}')
+    return tls
+
+
+def make_frame(frame_type: int, flags: int = 0, stream_id: int = 0, payload: 
bytes = b'') -> bytes:
+    '''Serialize an HTTP/2 frame.
+
+    :param frame_type: The frame type.
+    :param flags: The frame flags.
+    :param stream_id: The stream the frame belongs to.
+    :param payload: The frame payload.
+    :return: The serialized frame.
+    '''
+    return (len(payload).to_bytes(3, 'big') + bytes([frame_type, flags]) + 
(stream_id & 0x7fffffff).to_bytes(4, 'big') + payload)
+
+
+def read_exact(sock: ssl.SSLSocket, size: int) -> bytes:
+    '''Read exactly @a size bytes from @a sock.
+
+    :param sock: The socket to read from.
+    :param size: The number of bytes to read.
+    :return: The bytes read.
+    '''
+    chunks: List[bytes] = []
+    remaining = size
+    while remaining > 0:
+        chunk = sock.recv(remaining)
+        if not chunk:
+            raise EOFError('socket closed')
+        chunks.append(chunk)
+        remaining -= len(chunk)
+    return b''.join(chunks)
+
+
+def read_frame(sock: ssl.SSLSocket) -> Tuple[int, int, int, bytes]:
+    '''Read a single HTTP/2 frame from @a sock.
+
+    :param sock: The socket to read from.
+    :return: The frame type, flags, stream id, and payload.
+    '''
+    header = read_exact(sock, 9)
+    length = int.from_bytes(header[0:3], 'big')
+    frame_type = header[3]
+    flags = header[4]
+    stream_id = int.from_bytes(header[5:9], 'big') & 0x7fffffff
+    payload = read_exact(sock, length)
+    return frame_type, flags, stream_id, payload
+
+
+def complete_handshake(sock: ssl.SSLSocket) -> None:
+    '''Exchange the connection preface and SETTINGS frames.
+
+    The handshake is completed before the request is sent so that the client
+    does not have to interleave reads with the deliberate delay before the
+    CONTINUATION frame.
+
+    :param sock: The socket to handshake on.
+    '''
+    sock.sendall(CONNECTION_PREFACE)
+    sock.sendall(make_frame(FRAME_TYPE_SETTINGS))
+
+    acked_peer_settings = False
+    received_settings_ack = False
+    while not (acked_peer_settings and received_settings_ack):
+        frame_type, flags, _, _ = read_frame(sock)
+        if frame_type != FRAME_TYPE_SETTINGS:
+            continue
+        if flags & FLAG_ACK:
+            received_settings_ack = True
+        else:
+            sock.sendall(make_frame(FRAME_TYPE_SETTINGS, FLAG_ACK, 0))
+            acked_peer_settings = True
+
+
+def request_block(path: str, uuid: str) -> bytes:
+    '''HPACK encode the request header block.
+
+    :param path: The value for the :path pseudo header.
+    :param uuid: The value for the uuid header that the Proxy Verifier server
+      keys transactions off of.
+    :return: The encoded header block.
+    '''
+    return hpack.Encoder().encode(
+        [
+            (':method', 'GET'),
+            (':scheme', 'https'),
+            (':authority', 'example.com'),
+            (':path', path),
+            ('uuid', uuid),
+        ])
+
+
+def read_until_stream_ends(sock: ssl.SSLSocket, start: float) -> bool:
+    '''Read frames until ATS ends the stream or the connection.
+
+    :param sock: The socket to read from.
+    :param start: The monotonic time at which the request HEADERS frame was 
sent.
+    :return: Whether a response status was received for the request.
+    '''
+    decoder = hpack.Decoder()
+    header_fragments: List[bytes] = []
+    got_status = False
+
+    while True:
+        frame_type, flags, stream_id, payload = read_frame(sock)
+
+        # Nothing is written back here. While ATS is waiting for a CONTINUATION
+        # frame, any other frame from the client -- a SETTINGS ACK included --
+        # is a connection level PROTOCOL_ERROR, which would mask the timeout
+        # under test.
+        if frame_type == FRAME_TYPE_GOAWAY:
+            last_stream_id = int.from_bytes(payload[0:4], 'big') & 0x7fffffff
+            error_code = int.from_bytes(payload[4:8], 'big')
+            print(f'GOAWAY error_code={error_code} 
last_stream_id={last_stream_id} '
+                  f'elapsed={time.monotonic() - start:.2f}')
+            return got_status
+
+        if stream_id != STREAM_ID:
+            continue
+
+        if frame_type == FRAME_TYPE_RST_STREAM:
+            error_code = int.from_bytes(payload[0:4], 'big')
+            print(f'stream {stream_id}: RST_STREAM error_code={error_code} 
elapsed={time.monotonic() - start:.2f}')
+            return got_status
+
+        if frame_type in (FRAME_TYPE_HEADERS, FRAME_TYPE_CONTINUATION):
+            header_fragments.append(payload)
+            if flags & FLAG_END_HEADERS:
+                fields = dict(decoder.decode(b''.join(header_fragments)))
+                header_fragments = []
+                status = fields.get(':status')
+                if status is not None:
+                    got_status = True
+                    print(f'stream {stream_id}: status={status} 
elapsed={time.monotonic() - start:.2f}')
+
+        if (flags & FLAG_END_STREAM) and frame_type in (FRAME_TYPE_DATA, 
FRAME_TYPE_HEADERS):
+            print(f'stream {stream_id}: END_STREAM elapsed={time.monotonic() - 
start:.2f}')
+            return got_status
+
+
+def check_elapsed(elapsed: float, min_elapsed: Optional[float], max_elapsed: 
Optional[float]) -> bool:
+    '''Verify that ATS ended the stream within the expected window.
+
+    :param elapsed: How long ATS took to end the stream, in seconds.
+    :param min_elapsed: The lower bound, or None for no lower bound.
+    :param max_elapsed: The upper bound, or None for no upper bound.
+    :return: Whether @a elapsed is within the expected window.
+    '''
+    if min_elapsed is not None and elapsed < min_elapsed:
+        print(f'FAIL: ATS ended the stream after {elapsed:.2f}s, sooner than 
the expected {min_elapsed}s', file=sys.stderr)
+        return False
+    if max_elapsed is not None and elapsed > max_elapsed:
+        print(f'FAIL: ATS ended the stream after {elapsed:.2f}s, later than 
the expected {max_elapsed}s', file=sys.stderr)
+        return False
+    print(f'timing ok: elapsed={elapsed:.2f}')
+    return True
+
+
+def run(args: argparse.Namespace) -> int:
+    '''Send the request and report what ATS does with the stream.
+
+    :param args: The parsed command line arguments.
+    :return: The process exit code.
+    '''
+    block = request_block(args.path, args.uuid)
+    print(f'end_headers={args.end_headers} 
continuation_delay={args.continuation_delay}')
+
+    with make_socket(args.port, args.socket_timeout) as sock:
+        complete_handshake(sock)
+
+        flags = FLAG_END_STREAM
+        fragment = block
+        if args.end_headers:
+            flags |= FLAG_END_HEADERS
+        else:
+            # Withholding END_HEADERS leaves ATS waiting for a CONTINUATION
+            # frame, which is what incomplete_header_timeout_in bounds.
+            fragment = block[:len(block) // 2]
+        start = time.monotonic()
+        sock.sendall(make_frame(FRAME_TYPE_HEADERS, flags, STREAM_ID, 
fragment))
+
+        if args.continuation_delay is not None:
+            time.sleep(args.continuation_delay)
+            print(f'sending CONTINUATION elapsed={time.monotonic() - 
start:.2f}')
+            sock.sendall(make_frame(FRAME_TYPE_CONTINUATION, FLAG_END_HEADERS, 
STREAM_ID, block[len(block) // 2:]))
+
+        try:
+            got_status = read_until_stream_ends(sock, start)
+        except EOFError:
+            print(f'EOF elapsed={time.monotonic() - start:.2f}')
+            got_status = False
+        except socket.timeout:
+            print(f'FAIL: no response from ATS within the 
{args.socket_timeout}s socket timeout', file=sys.stderr)
+            return 1
+
+    elapsed = time.monotonic() - start
+    print(f'got_status={got_status}')
+    return 0 if check_elapsed(elapsed, args.min_elapsed, args.max_elapsed) 
else 1
+
+
+def parse_args() -> argparse.Namespace:
+    '''Parse the command line arguments.
+
+    :return: The parsed arguments.
+    '''
+    parser = argparse.ArgumentParser(description=__doc__)
+    parser.add_argument('port', type=int, help='ATS TLS port')
+    parser.add_argument('--path', default='/incomplete', help='request :path')
+    parser.add_argument('--uuid', default='incomplete_header', help='request 
uuid header')
+    parser.add_argument(
+        '--end-headers', action='store_true', help='send the complete header 
block with END_HEADERS in a single HEADERS frame')
+    parser.add_argument(
+        '--continuation-delay',
+        type=float,
+        default=None,
+        help='seconds after the HEADERS frame to send the END_HEADERS 
CONTINUATION frame; omit to never send it')
+    parser.add_argument('--min-elapsed', type=float, default=None, help='fail 
if ATS ends the stream sooner than this')
+    parser.add_argument('--max-elapsed', type=float, default=None, help='fail 
if ATS ends the stream later than this')
+    parser.add_argument('--socket-timeout', type=float, default=60.0, 
help='socket timeout in seconds')
+    return parser.parse_args()
+
+
+def main() -> int:
+    return run(parse_args())
+
+
+if __name__ == '__main__':
+    raise SystemExit(main())
diff --git a/tests/gold_tests/timeout/http2_incomplete_header_timeout.test.py 
b/tests/gold_tests/timeout/http2_incomplete_header_timeout.test.py
new file mode 100644
index 0000000000..aad0449282
--- /dev/null
+++ b/tests/gold_tests/timeout/http2_incomplete_header_timeout.test.py
@@ -0,0 +1,207 @@
+#  Licensed to the Apache Software Foundation (ASF) under one
+#  or more contributor license agreements.  See the NOTICE file
+#  distributed with this work for additional information
+#  regarding copyright ownership.  The ASF licenses this file
+#  to you under the Apache License, Version 2.0 (the
+#  "License"); you may not use this file except in compliance
+#  with the License.  You may obtain a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+#  Unless required by applicable law or agreed to in writing, software
+#  distributed under the License is distributed on an "AS IS" BASIS,
+#  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+#  See the License for the specific language governing permissions and
+#  limitations under the License.
+
+import sys
+from typing import Optional
+
+Test.Summary = 'Verify http2.incomplete_header_timeout_in'
+
+# VC_EVENT_ACTIVE_TIMEOUT. The incomplete header timeout is an active timeout 
on
+# the stream, so ATS reports this event rather than VC_EVENT_INACTIVITY_TIMEOUT
+# (105).
+VC_EVENT_ACTIVE_TIMEOUT = 106
+
+# Http2ErrorCode::HTTP2_ERROR_COMPRESSION_ERROR. ATS tears down the connection
+# rather than just the stream because the undelivered CONTINUATION frame leaves
+# its HPACK dynamic table out of sync.
+HTTP2_ERROR_COMPRESSION_ERROR = 9
+
+
+class TestHttp2IncompleteHeaderTimeout:
+    """Configure a test for http2.incomplete_header_timeout_in.
+
+    The client sends a HEADERS frame without the END_HEADERS flag, which leaves
+    ATS waiting for a CONTINUATION frame. incomplete_header_timeout_in bounds
+    how long ATS waits.
+    """
+
+    client_script: str = 'http2_incomplete_header_client.py'
+    replay_file: str = 'replay/http2_incomplete_header_timeout.replay.yaml'
+    counter: int = 0
+
+    def __init__(
+            self,
+            name: str,
+            incomplete_header_timeout_in: int,
+            path: str,
+            uuid: str,
+            end_headers: bool = False,
+            continuation_delay: Optional[float] = None,
+            min_elapsed: Optional[float] = None,
+            max_elapsed: Optional[float] = None,
+            expect_timeout: bool = False):
+        """Initialize the test.
+
+        :param name: The name of the test run.
+        :param incomplete_header_timeout_in: The value to configure for
+          proxy.config.http2.incomplete_header_timeout_in.
+        :param path: The :path pseudo header the client requests.
+        :param uuid: The uuid of the replay file transaction to request.
+        :param end_headers: Whether the client sends the complete header block
+          with END_HEADERS in a single HEADERS frame.
+        :param continuation_delay: How long after the HEADERS frame the client
+          waits before sending the END_HEADERS CONTINUATION frame. None means
+          that the client never sends it.
+        :param min_elapsed: Fail if ATS ends the stream sooner than this.
+        :param max_elapsed: Fail if ATS ends the stream later than this.
+        :param expect_timeout: Whether ATS is expected to time out the stream.
+        """
+        self._name = name
+        self._incomplete_header_timeout_in = incomplete_header_timeout_in
+        self._path = path
+        self._uuid = uuid
+        self._end_headers = end_headers
+        self._continuation_delay = continuation_delay
+        self._min_elapsed = min_elapsed
+        self._max_elapsed = max_elapsed
+        self._expect_timeout = expect_timeout
+
+    def _configure_server(self, tr: 'TestRun') -> None:
+        """Configure the origin server.
+
+        :param tr: The TestRun object to associate the server process with.
+        """
+        self._server = tr.AddVerifierServerProcess(f'server-{self.counter}', 
self.replay_file)
+
+    def _configure_traffic_server(self, tr: 'TestRun') -> None:
+        """Configure Traffic Server.
+
+        :param tr: The TestRun object to associate the ts process with.
+        """
+        ts = tr.MakeATSProcess(f'ts-{self.counter}', enable_tls=True, 
enable_cache=False)
+        self._ts = ts
+
+        ts.addSSLfile('ssl/cert.crt')
+        ts.addSSLfile('ssl/private-key.key')
+        ts.Disk.ssl_multicert_yaml.AddLines(
+            f"""
+ssl_multicert:
+  - dest_ip: "*"
+    ssl_cert_name: {ts.Variables.SSLDir}/cert.crt
+    ssl_key_name: {ts.Variables.SSLDir}/private-key.key
+""".split('\n'))
+
+        ts.Disk.records_config.update(
+            {
+                'proxy.config.diags.debug.enabled': 1,
+                'proxy.config.diags.debug.tags': 'http2',
+                'proxy.config.ssl.server.cert.path': ts.Variables.SSLDir,
+                'proxy.config.ssl.server.private_key.path': 
ts.Variables.SSLDir,
+                'proxy.config.http2.incomplete_header_timeout_in': 
self._incomplete_header_timeout_in,
+            })
+
+        ts.Disk.remap_config.AddLine(f'map / 
http://127.0.0.1:{self._server.Variables.http_port}')
+
+    def _configure_client(self, tr: 'TestRun') -> None:
+        """Configure the ad hoc HTTP/2 client.
+
+        :param tr: The TestRun object to associate the client process with.
+        """
+        tr.Setup.Copy(self.client_script)
+        command = (
+            f'{sys.executable} {self.client_script} 
{self._ts.Variables.ssl_port} '
+            f'--path {self._path} --uuid {self._uuid}')
+        if self._end_headers:
+            command += ' --end-headers'
+        if self._continuation_delay is not None:
+            command += f' --continuation-delay {self._continuation_delay}'
+        if self._min_elapsed is not None:
+            command += f' --min-elapsed {self._min_elapsed}'
+        if self._max_elapsed is not None:
+            command += f' --max-elapsed {self._max_elapsed}'
+
+        tr.Processes.Default.Command = command
+        tr.Processes.Default.ReturnCode = 0
+
+    def _configure_expectations(self, tr: 'TestRun') -> None:
+        """Configure the testers for the test run.
+
+        :param tr: The TestRun object to associate the testers with.
+        """
+        timeout_error = 'ERROR: HTTP/2 stream error timeout'
+        if self._expect_timeout:
+            tr.Processes.Default.Streams.stdout += Testers.ContainsExpression(
+                f'GOAWAY error_code={HTTP2_ERROR_COMPRESSION_ERROR} 
last_stream_id=1',
+                'ATS should close the connection with COMPRESSION_ERROR 
because the HPACK table is out of sync.')
+            self._ts.Disk.traffic_out.Content += Testers.ContainsExpression(
+                f'timeout event={VC_EVENT_ACTIVE_TIMEOUT}',
+                'ATS should time out the incomplete header with the stream 
active timeout.')
+            # The default diags.log check rejects any ERROR:, but this test
+            # expects the incomplete header timeout to be reported.
+            self._ts.Disk.diags_log.Content = Testers.ContainsExpression(
+                timeout_error, 'ATS should log the incomplete header timeout.')
+        else:
+            tr.Processes.Default.Streams.stdout += Testers.ContainsExpression(
+                'stream 1: status=200', 'ATS should proxy the request rather 
than time out the stream.')
+            tr.Processes.Default.Streams.stdout += 
Testers.ExcludesExpression('GOAWAY', 'ATS should not close the connection.')
+            self._ts.Disk.diags_log.Content += Testers.ExcludesExpression(
+                timeout_error, 'ATS should not report an incomplete header 
timeout.')
+
+    def run(self) -> None:
+        """Run the test."""
+        tr = Test.AddTestRun(self._name)
+        self._configure_server(tr)
+        self._configure_traffic_server(tr)
+        self._configure_client(tr)
+        self._configure_expectations(tr)
+
+        tr.Processes.Default.StartBefore(self._server)
+        tr.Processes.Default.StartBefore(self._ts)
+        TestHttp2IncompleteHeaderTimeout.counter += 1
+
+
+# A client that never completes the header block should be timed out at the
+# configured timeout, well before the 10 second default.
+TestHttp2IncompleteHeaderTimeout(
+    'Incomplete header block times out',
+    incomplete_header_timeout_in=3,
+    path='/incomplete',
+    uuid='incomplete_header',
+    continuation_delay=None,
+    min_elapsed=2.5,
+    max_elapsed=7,
+    expect_timeout=True).run()
+
+# A client that completes the header block before the timeout expires should be
+# proxied normally.
+TestHttp2IncompleteHeaderTimeout(
+    'CONTINUATION frame arrives before the timeout',
+    incomplete_header_timeout_in=5,
+    path='/incomplete',
+    uuid='incomplete_header',
+    continuation_delay=1,
+    expect_timeout=False).run()
+
+# The timeout is canceled when the transaction starts, so a slow origin
+# response must not be mistaken for an incomplete header block.
+TestHttp2IncompleteHeaderTimeout(
+    'Timeout is canceled once the transaction starts',
+    incomplete_header_timeout_in=2,
+    path='/delayed',
+    uuid='delayed_response',
+    end_headers=True,
+    min_elapsed=4.5,
+    expect_timeout=False).run()
diff --git 
a/tests/gold_tests/timeout/replay/http2_incomplete_header_timeout.replay.yaml 
b/tests/gold_tests/timeout/replay/http2_incomplete_header_timeout.replay.yaml
new file mode 100644
index 0000000000..3bfa0315a3
--- /dev/null
+++ 
b/tests/gold_tests/timeout/replay/http2_incomplete_header_timeout.replay.yaml
@@ -0,0 +1,63 @@
+#  Licensed to the Apache Software Foundation (ASF) under one
+#  or more contributor license agreements.  See the NOTICE file
+#  distributed with this work for additional information
+#  regarding copyright ownership.  The ASF licenses this file
+#  to you under the Apache License, Version 2.0 (the
+#  "License"); you may not use this file except in compliance
+#  with the License.  You may obtain a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+#  Unless required by applicable law or agreed to in writing, software
+#  distributed under the License is distributed on an "AS IS" BASIS,
+#  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+#  See the License for the specific language governing permissions and
+#  limitations under the License.
+
+# The client side of these transactions is driven by
+# http2_incomplete_header_client.py, which fragments the request header block.
+# Only the server side of this file is used.
+
+meta:
+  version: "1.0"
+
+sessions:
+- transactions:
+
+  # Requested by the client that completes its header block with a
+  # CONTINUATION frame before incomplete_header_timeout_in expires.
+  - client-request:
+      method: GET
+      url: /incomplete
+      version: '1.1'
+      headers:
+        fields:
+        - [ Host, example.com ]
+        - [ uuid, incomplete_header ]
+
+    server-response:
+      status: 200
+      reason: OK
+      headers:
+        fields:
+        - [ Content-Length, 16 ]
+
+  # Requested by the client that sends a complete header block. The response is
+  # delayed past incomplete_header_timeout_in to verify that the timeout is
+  # canceled once the transaction starts.
+  - client-request:
+      method: GET
+      url: /delayed
+      version: '1.1'
+      headers:
+        fields:
+        - [ Host, example.com ]
+        - [ uuid, delayed_response ]
+
+    server-response:
+      delay: 5s
+      status: 200
+      reason: OK
+      headers:
+        fields:
+        - [ Content-Length, 16 ]

Reply via email to