|
11 | 11 | from __future__ import annotations |
12 | 12 |
|
13 | 13 | import datetime |
| 14 | +import http.client |
14 | 15 | import json |
15 | 16 | import re |
16 | | -import urllib.error |
17 | | -import urllib.request |
| 17 | +import socket |
| 18 | +import ssl |
| 19 | +import threading |
18 | 20 | from typing import Any |
| 21 | +from urllib.parse import urlsplit |
19 | 22 |
|
20 | 23 |
|
21 | 24 | _SQL_IDENTIFIER = re.compile(r"^[a-zA-Z_][a-zA-Z0-9_]*$") |
22 | 25 | # Upper bound on how much of a non-2xx response body is read for diagnostics. |
23 | 26 | _MAX_ERROR_BODY_BYTES = 64 * 1024 |
24 | 27 |
|
25 | 28 |
|
26 | | -class _NoRedirect(urllib.request.HTTPRedirectHandler): |
27 | | - """Refuse every redirect so a 3xx surfaces as a failed status. |
28 | | -
|
29 | | - Following a redirect would re-send a POST as a body-less GET and forward |
30 | | - configured credential headers to the new location. |
31 | | - """ |
32 | | - |
33 | | - def redirect_request( # type: ignore[override] # stdlib signature has no hints |
34 | | - self, req: Any, fp: Any, code: int, msg: str, headers: Any, newurl: str |
35 | | - ) -> None: |
36 | | - return None |
37 | | - |
38 | | - |
39 | | -_OPENER = urllib.request.build_opener(_NoRedirect()) |
40 | | - |
41 | | - |
42 | 29 | def compact_dumps(value: Any) -> str: |
43 | 30 | """Serialize ``value`` as compact JSON (no whitespace, non-ASCII preserved). |
44 | 31 |
|
@@ -86,20 +73,74 @@ def http_send( |
86 | 73 | """Send one HTTP request and return ``(status, reason, error_text)``. |
87 | 74 |
|
88 | 75 | A non-2xx status is returned, not raised, so callers build their own error |
89 | | - message. Redirects are not followed: a 3xx is returned like any other |
90 | | - failure. ``error_text`` is the first ``_MAX_ERROR_BODY_BYTES`` of a non-2xx |
91 | | - response body and empty on success; a success body is never read. Network |
92 | | - errors and timeouts propagate. |
| 76 | + message. Redirects are never followed: a 3xx is returned like any other |
| 77 | + failure, so a POST is not re-sent as a body-less GET and configured |
| 78 | + credential headers never reach another origin. ``error_text`` is the first |
| 79 | + ``_MAX_ERROR_BODY_BYTES`` of a non-2xx response body and empty on success; |
| 80 | + a success body is never read. |
| 81 | +
|
| 82 | + ``timeout`` (seconds) is a deadline for the whole request: connect, send, |
| 83 | + and receiving the status and headers. When it expires the connection is |
| 84 | + shut down and ``TimeoutError`` is raised, even against a peer that keeps |
| 85 | + the socket alive by trickling bytes. ``None`` means no limit. Network |
| 86 | + errors propagate. |
93 | 87 | """ |
94 | | - request = urllib.request.Request(url, data=body, method=method) |
95 | | - for key, value in headers.items(): |
96 | | - request.add_header(key, value) |
| 88 | + parts = urlsplit(url) |
| 89 | + if parts.scheme not in ("http", "https") or not parts.hostname: |
| 90 | + msg = f"Unsupported URL: {url!r} (need http:// or https:// with a host)" |
| 91 | + raise ValueError(msg) |
| 92 | + path = parts.path or "/" |
| 93 | + if parts.query: |
| 94 | + path = f"{path}?{parts.query}" |
| 95 | + conn: http.client.HTTPConnection |
| 96 | + if parts.scheme == "https": |
| 97 | + conn = http.client.HTTPSConnection( |
| 98 | + parts.hostname, |
| 99 | + parts.port, |
| 100 | + timeout=timeout, |
| 101 | + context=ssl.create_default_context(), |
| 102 | + ) |
| 103 | + else: |
| 104 | + conn = http.client.HTTPConnection(parts.hostname, parts.port, timeout=timeout) |
| 105 | + |
| 106 | + expired = threading.Event() |
| 107 | + |
| 108 | + def _expire() -> None: |
| 109 | + # Wake any blocked read; the request then fails and is reported as a |
| 110 | + # timeout below. |
| 111 | + expired.set() |
| 112 | + sock = conn.sock |
| 113 | + if sock is not None: |
| 114 | + try: |
| 115 | + sock.shutdown(socket.SHUT_RDWR) |
| 116 | + except OSError: |
| 117 | + pass |
| 118 | + |
| 119 | + timer: threading.Timer | None = None |
| 120 | + if timeout is not None: |
| 121 | + timer = threading.Timer(timeout, _expire) |
| 122 | + timer.daemon = True |
| 123 | + timer.start() |
97 | 124 | try: |
98 | | - with _OPENER.open(request, timeout=timeout) as response: # noqa: S310 |
99 | | - return int(response.status), str(response.reason or ""), "" |
100 | | - except urllib.error.HTTPError as exc: |
101 | | - try: |
102 | | - detail = exc.read(_MAX_ERROR_BODY_BYTES).decode("utf-8", errors="replace") |
103 | | - except Exception: # noqa: BLE001 - the body is best-effort detail only |
104 | | - detail = "" |
105 | | - return int(exc.code), str(exc.reason or ""), detail |
| 125 | + conn.request(method, path, body=body, headers=headers) |
| 126 | + response = conn.getresponse() |
| 127 | + status = int(response.status) |
| 128 | + reason = str(response.reason or "") |
| 129 | + detail = "" |
| 130 | + if not 200 <= status < 300: |
| 131 | + try: |
| 132 | + detail = response.read(_MAX_ERROR_BODY_BYTES).decode( |
| 133 | + "utf-8", errors="replace" |
| 134 | + ) |
| 135 | + except Exception: # noqa: BLE001 - the body is best-effort detail only |
| 136 | + detail = "" |
| 137 | + return status, reason, detail |
| 138 | + except Exception as exc: |
| 139 | + if expired.is_set(): |
| 140 | + msg = f"request to {url} exceeded {timeout}s" |
| 141 | + raise TimeoutError(msg) from exc |
| 142 | + raise |
| 143 | + finally: |
| 144 | + if timer is not None: |
| 145 | + timer.cancel() |
| 146 | + conn.close() |
0 commit comments