# HTTP JSON 客户端:请求成功不等于数据可信
学习目标:完成真实 HTTP 请求、超时、JSON 解码、运行时字段校验和用户错误报告,并用程序自己启动的 loopback 服务重现成功与失败。
前置知识:异常、函数、JSON 对象;Python 只需把线程理解为让测试服务器与阻塞客户端同时工作,JavaScript 需理解 Promise 和 await。选择 Python 与 JavaScript,对比阻塞请求和事件驱动请求的资源归属;均使用标准库,不引入第三方 HTTP 框架。
# 问题与边界
客户端读取用户资料,CLI 为 HOST PORT PATH 三个参数,执行一次 GET;无参数时启动自己的 127.0.0.1:随机端口 服务并依次请求预设路径。
- 只演示明文 HTTP,目标主机和路径由可信调用者提供;不处理 URL 解析、认证、代理、TLS、重定向、重试或任意不可信地址访问。不要向该客户端传密码或令牌。
- 只接受状态码 200;其他状态首先报告
HTTP N,不把错误正文当成功 JSON。 - 200 响应正文必须是无 BOM 的 UTF-8 JSON。JSON 语法失败或编码错误报告
invalid JSON。不凭 Content-Type 代替解析,本例也不要求该头一定存在。 - 顶层必须是对象,
id必须是 1~65535 范围的整数值,不能是字符串或布尔值;name必须是 1~20 个 ASCII 字母。缺少字段、数组、null、范围错误、错误类型均报告invalid user。数值写成1.0仍代表整数值,两个实现都接受。 - 未知字段忽略,重复 JSON 属性沿用解析器的后项覆盖语义;这不是签名 JSON、通用 schema 或重复键审计器。
- 默认超时为 2 秒;演示
/timeout使用 0.1 秒并让服务器保持沉默。超时报timeout,其他传输失败报network error。CLI 失败写 stderr 并返回 1,不打印无效用户。
响应由受控小型服务提供,正文会完整缓存;超大正文、慢速持续发送以及整次请求总时限不在本工具保证内。超时覆盖的具体阶段在下节说明,不能把一个参数宣称为所有网络风险的解决方案。
# 关键差异
| 维度 | Python | JavaScript |
|---|---|---|
| 请求控制流 | 阻塞 HTTPConnection |
http.get 事件包装为 Promise |
| 超时语义 | 套接字阻塞操作超时,单位秒 | 连接后套接字空闲超时,单位毫秒 |
| 超时后的动作 | 异常传播,finally 关闭连接 |
超时回调必须显式销毁请求 |
| 运行时整数判定 | 排除 bool,检查整数值与范围 | Number.isInteger 与范围 |
Python 的 HTTPConnection 超时作用于阻塞操作,不是请求总预算。Node.js 的 request.setTimeout 在套接字连接后设置超时;单纯设置超时不会自动中止请求,因此下面主动 destroy。这两个 API 不提供完全等价的 DNS、连接和总耗时保证;本例共同验证的是已连接 loopback 服务不返回数据时能够停止等待。
# 各语言实现
# Python
执行 python main.py 运行本地服务与客户端;另可执行 python main.py 127.0.0.1 8080 /user 请求自己已有的 HTTP 服务。无第三方依赖,无文件写入。
Pythonimport http.client
import json
import re
import sys
import threading
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
def reject_constant(text):
raise ValueError("non-JSON numeric constant")
def validate_user(value):
if not isinstance(value, dict):
raise ValueError("invalid user")
identifier, name = value.get("id"), value.get("name")
if (type(identifier) not in (int, float)
or not 1 <= identifier <= 65535 or int(identifier) != identifier
or not isinstance(name, str)
or re.fullmatch(r"[A-Za-z]{1,20}", name) is None):
raise ValueError("invalid user")
return {"id": int(identifier), "name": name}
def fetch_user(host, port, path, timeout=2):
connection = http.client.HTTPConnection(host, port, timeout=timeout)
try:
connection.request("GET", path, headers={"Accept": "application/json"})
with connection.getresponse() as response:
if response.status != 200:
raise ValueError(f"HTTP {response.status}")
body = response.read()
try:
value = json.loads(body.decode("utf-8"), parse_constant=reject_constant)
except (ValueError, UnicodeError) as error:
raise ValueError("invalid JSON") from error
return validate_user(value)
except TimeoutError as error:
raise ValueError("timeout") from error
except (OSError, http.client.HTTPException) as error:
raise ValueError("network error") from error
finally:
connection.close()
def demo():
release = threading.Event()
routes = {
"/ok": (200, b'{"id":7,"name":"Ada"}'),
"/status": (503, b'unavailable'),
"/json": (200, b'{'),
"/fields": (200, b'{"id":"7","name":"Ada"}'),
}
class Handler(BaseHTTPRequestHandler):
def log_message(self, format, *args):
pass # 演示不输出访问日志;请求结果由客户端报告。
def do_GET(self):
if self.path == "/timeout":
release.wait()
self.close_connection = True
return
if self.path == "/disconnect":
self.close_connection = True
return
status, body = routes.get(self.path, (404, b'not found'))
self.send_response(status)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
for identifier in [1, 65535, 1.0]:
assert validate_user({"id": identifier, "name": "A"})["id"] == identifier
assert validate_user({"id": 1, "name": "A" * 20})["name"] == "A" * 20
for value in [None, [], {}, {"id": True, "name": "Ada"},
{"id": 0, "name": "Ada"}, {"id": 65536, "name": "Ada"},
{"id": 1.5, "name": "Ada"}, {"id": 1, "name": ""},
{"id": 1, "name": "A" * 21}, {"id": 1, "name": "艾达"}]:
try:
validate_user(value)
except ValueError as error:
assert str(error) == "invalid user"
else:
raise AssertionError("invalid user accepted")
server = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
thread = threading.Thread(target=server.serve_forever)
thread.start()
try:
port = server.server_address[1]
user = fetch_user("127.0.0.1", port, "/ok")
assert user == {"id": 7, "name": "Ada"}
print(f"user: {user['id']} {user['name']}")
for path, message in [("/status", "HTTP 503"), ("/json", "invalid JSON"),
("/fields", "invalid user"), ("/timeout", "timeout"),
("/disconnect", "network error")]:
try:
fetch_user("127.0.0.1", port, path, 0.1 if path == "/timeout" else 2)
except ValueError as error:
assert str(error) == message
print("error: " + str(error))
else:
raise AssertionError("failing response accepted")
finally:
release.set()
server.shutdown()
server.server_close()
thread.join()
def main():
if len(sys.argv) == 1:
demo()
return 0
if (len(sys.argv) != 4 or re.fullmatch(r"[0-9]{1,5}", sys.argv[2]) is None
or not 1 <= int(sys.argv[2]) <= 65535 or not sys.argv[3].startswith("/")):
print("usage: python main.py [HOST PORT /PATH]", file=sys.stderr)
return 2
try:
user = fetch_user(sys.argv[1], int(sys.argv[2]), sys.argv[3])
except ValueError as error:
print(f"error: {error}", file=sys.stderr)
return 1
print(f"user: {user['id']} {user['name']}")
return 0
if __name__ == "__main__":
sys.exit(main())
Python 中 bool 是 int 的子类,因此 isinstance(True, int) 不能表达本协议;这里精确限定数值类型再检查范围。标准 json 默认能接受非标准的 NaN 和 Infinity,本例用 parse_constant 拒绝,避免与 JavaScript JSON 解析的边界不一致。
服务器绑定端口 0 让系统分配空闲端口;仅绑定 loopback,不对局域网开放。/timeout 等待释放事件,而非睡眠后继续向已断开的客户端写数据;清理时先释放处理器,再停止服务。http.server 只用于本地验证,不建议作生产服务,见官方安全说明。
# JavaScript
位于独立 CommonJS 目录。执行 node main.js;独立请求为 node main.js 127.0.0.1 8080 /user。不依赖浏览器或全局 fetch,因此不引入额外的 fetch 运行时版本条件。
JavaScriptconst assert = require('node:assert/strict');
const http = require('node:http');
const { TextDecoder } = require('node:util');
function validateUser(value) {
if (value === null || typeof value !== 'object' || Array.isArray(value) ||
!Number.isInteger(value.id) || value.id < 1 || value.id > 65535 ||
typeof value.name !== 'string' || !/^[A-Za-z]{1,20}$/.test(value.name) ||
value.name.includes('\n')) throw new Error('invalid user');
return { id: value.id, name: value.name };
}
function fetchUser(host, port, path, timeout = 2000) {
return new Promise((resolve, reject) => {
let timedOut = false;
const request = http.get({ hostname: host, port, path, agent: false,
headers: { Accept: 'application/json' } }, response => {
const chunks = [];
response.on('error', error => reject(new Error(
timedOut ? 'timeout' : 'network error', { cause: error })));
if (response.statusCode !== 200) {
reject(new Error(`HTTP ${response.statusCode}`));
response.destroy();
return;
}
response.on('data', chunk => chunks.push(chunk));
response.on('end', () => {
let value;
try {
const text = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true })
.decode(Buffer.concat(chunks));
value = JSON.parse(text);
} catch (error) {
reject(new Error('invalid JSON', { cause: error }));
return;
}
try { resolve(validateUser(value)); }
catch (error) { reject(error); }
});
});
request.setTimeout(timeout, () => {
timedOut = true;
request.destroy(new Error('timeout'));
});
request.on('error', error => reject(new Error(
timedOut ? 'timeout' : 'network error', { cause: error })));
});
}
async function demo() {
for (const id of [1, 65535, 1.0]) {
assert.equal(validateUser({ id, name: 'A' }).id, id);
}
assert.equal(validateUser({ id: 1, name: 'A'.repeat(20) }).name.length, 20);
for (const value of [null, [], {}, { id: true, name: 'Ada' },
{ id: 0, name: 'Ada' }, { id: 65536, name: 'Ada' },
{ id: 1.5, name: 'Ada' }, { id: 1, name: '' },
{ id: 1, name: 'A'.repeat(21) }, { id: 1, name: '艾达' },
{ id: 1, name: 'Ada\n' }]) {
assert.throws(() => validateUser(value), { message: 'invalid user' });
}
const routes = new Map([
['/ok', [200, '{"id":7,"name":"Ada"}']],
['/status', [503, 'unavailable']],
['/json', [200, '{']],
['/fields', [200, '{"id":"7","name":"Ada"}']],
]);
const sockets = new Set();
const server = http.createServer((request, response) => {
if (request.url === '/timeout') return;
if (request.url === '/disconnect') { request.socket.destroy(); return; }
const [status, body] = routes.get(request.url) ?? [404, 'not found'];
response.writeHead(status, { 'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(body) });
response.end(body);
});
server.on('connection', socket => {
sockets.add(socket);
socket.on('close', () => sockets.delete(socket));
});
await new Promise((resolve, reject) => {
server.once('error', reject);
server.listen(0, '127.0.0.1', resolve);
});
try {
const port = server.address().port;
const user = await fetchUser('127.0.0.1', port, '/ok');
assert.deepEqual(user, { id: 7, name: 'Ada' });
console.log(`user: ${user.id} ${user.name}`);
for (const [path, message] of [['/status', 'HTTP 503'], ['/json', 'invalid JSON'],
['/fields', 'invalid user'], ['/timeout', 'timeout'], ['/disconnect', 'network error']]) {
try {
await fetchUser('127.0.0.1', port, path, path === '/timeout' ? 100 : 2000);
assert.fail('failing response accepted');
} catch (error) {
assert.equal(error.message, message);
console.log('error: ' + error.message);
}
}
} finally {
await new Promise((resolve, reject) => {
server.close(error => error ? reject(error) : resolve());
for (const socket of sockets) socket.destroy();
});
}
}
async function main() {
const args = process.argv.slice(2);
if (args.length === 0) return demo();
if (args.length !== 3 || !/^[0-9]{1,5}$/.test(args[1]) || args[1].includes('\n') ||
Number(args[1]) < 1 || Number(args[1]) > 65535 || !args[2].startsWith('/')) {
console.error('usage: node main.js [HOST PORT /PATH]');
process.exitCode = 2;
return;
}
try {
const user = await fetchUser(args[0], Number(args[1]), args[2]);
console.log(`user: ${user.id} ${user.name}`);
} catch (error) {
console.error(`error: ${error.message}`);
process.exitCode = 1;
}
}
main().catch(error => { console.error(error); process.exitCode = 1; });
Node.js http 返回字节流,不替应用解析 JSON,见 HTTP 模块说明。这里同时处理请求和响应的错误;请求已经拿到响应头,不代表响应正文一定能完整读完。agent: false 禁用连接复用,缩小演示的资源生命周期;本地服务记录并关闭自己的套接字,不依赖新版运行时的额外关闭 API。
# 迁移误区与适用限制
- 状态码成功等于业务成功:
/fields返回 200 且 JSON 语法正确,但id是字符串,必须在消费前拒绝。 - 类型声明等于验证:把结果写成 TypeScript 的
User断言,不会在运行时检查id。本篇只给 JavaScript,不增加一份仅有静态外观差异的实现。 - 超时等于取消:Node.js 的超时通知后若不销毁请求,调用方仍可能等待;本例销毁后不再尝试使用响应。服务端是否停止执行则是另一回事。
- 2 秒等于全程 2 秒:慢速但不断产生数据的对端可能持续延长总体耗时;DNS 与连接阶段的覆盖也不相同。需要总预算时,应选择或实现有明确整体 deadline 的客户端,而不是给这段代码换个参数名。
- 错误对象可以直接给用户:工具输出稳定类别,不打印整份响应或请求凭据;原始异常保留为 cause 供上层诊断。不存在自动重试或用空用户回退。
- 本地测试等于公网验证:loopback 能验证真实 socket、HTTP 状态、解析、超时和断连,但不证明 TLS、代理、防火墙或真实远端行为。服务代码只供隔离运行。
# 运行与验证
两个无参数程序都自行监听 loopback 随机端口,结束时关闭服务和连接,无需公网,不创建配置或数据文件。预期 stdout 为:
纯文本user: 7 Ada
error: HTTP 503
error: invalid JSON
error: invalid user
error: timeout
error: network error
内置网络场景覆盖成功、非 200、非法 JSON、字段错误、已连接但无响应、服务端直接断连;字段断言覆盖 id 两端点、上下界外、小数、布尔、缺字段、顶层数组/null、名字长度 0/1/20/21 和非 ASCII。预期错误由 demo 调用方消费,因此整体应正常退出。独立 CLI 失败返回 1,参数错误返回 2;不会在错误后继续打印用户。
版本基线见主题首页,执行状态和实际工具链见验证记录。上述输出是待执行比对的预期,不是执行证据。无效 UTF-8、BOM、NaN、重复属性、未知字段的策略由正文与实现定义,但未全部加入内置网络路由;整次请求总时限、超大响应和公网部署不在验证范围。