状态码需要放在请求方法的上下文里理解,它告诉客户端怎样解释本次 HTTP 响应。首位数字只能缩小类别,不能说明完整业务结果。例如,任务已经接受,仍可能没有完成;三开头的响应,也未必表示页面搬家。服务器应根据实际完成的事情选择具体状态,并提供相应响应头和内容,避免让一个看起来合理的数字承担它从未承诺的含义。

成功不只有一种含义

二百表示请求按照相应方法的语义成功。成功读取任务状态资源,可以如实返回“任务仍在等待”的表示;这不代表任务本身已经结束。二零一表示创建了一个或多个资源,提供 Location 时,它用于识别主要的新建资源。接口需要说明客户端接下来应操作哪个资源,而不是把所有二开头的响应都解释成“用户最初想要的整个业务已经完成”。

二零二表示请求已经接受处理,但尚未完成,也不会对最终一定执行成功作出保证。应提供观察进度的途径,并定义最终成功与失败,否则客户端很难区分任务缓慢与任务丢失。二零四表示请求成功完成且没有响应内容;不能因为框架平常总是序列化 JSON,就给这种响应也附加正文。具体状态对应的消息约束需要贯穿最终输出。

三开头也可能是在验证已有表示

三零一与三零八表达永久重定向,三零二与三零七表达临时重定向,并具有不同的方法保留规则。三零三可以让调用方在处理步骤之后转而读取另一个资源。请求携带正文或具有副作用时,更要明确选择:可能改变方法的跳转,与必须保留方法的跳转,不能随意互换。客户端是否跟随,也应依据它实际要完成的动作判断。

三零四是另一种情况:它回应条件 GET 或 HEAD,表示无需重新传输选中的表示。接收方按缓存规则使用合适的已存内容,并更新相关元数据。三零四没有响应内容,也不是要求客户端沿 Location 导航。此时显示为空,是协议预期,并不能证明服务器把文件弄丢了。如果调用方没有相应缓存表示,就不能把这个空正文直接当作完整资源交给上层。

坏掉的样子

把所有四开头都归为“不能重试”,会误处理限流等情况;把所有五开头都归为“立即重试”,又可能重复非幂等操作或加剧过载。五零三覆盖暂时的服务不可用,包括过载或维护,并不只用于计划停机。重试决策还要结合方法、服务器提示和客户端预算。状态类别提供线索,不是能够无条件套用的完整重试算法。

四零四没有保证缺失只是暂时的:它可以表示找不到当前表示,也可以表示服务器不愿披露该表示。四一零才传达可能永久不可用的含义。同样,四零三并不普遍证明调用者已通过身份验证。业务代码不能在这些数字上附加比协议更强的假设;响应正文也应符合接口已声明的契约,否则下游即使正确识别了状态,仍可能误解实际处理结果。

快速验证

保存为 status-contract.py,运行 python3 status-contract.py。实验通过真实回环 HTTP 请求操作一个很小的内存任务状态和固定资源。这里使用的 Python HTTP 客户端不会自动跟随 Location,也没有实现缓存,所以脚本中每一步后续动作都明确写出。它不需要真实队列或数据库,避免把未验证的后台处理能力混进状态语义实验。

from http.client import HTTPConnection
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from threading import Thread
import json

job = {'state': 'absent'}


class Handler(BaseHTTPRequestHandler):
    def reply(self, status, data=b'', headers=None):
        self.send_response(status)
        if status not in (204, 304):
            self.send_header('Content-Length', str(len(data)))
        for key, value in (headers or {}).items():
            self.send_header(key, value)
        self.end_headers()
        if status not in (204, 304):
            self.wfile.write(data)

    def do_POST(self):
        if self.path != '/jobs':
            self.reply(404)
            return
        job['state'] = 'pending'
        self.reply(202, b'{"state":"pending"}',
                   {'Content-Type': 'application/json', 'Location': '/jobs/42'})

    def do_GET(self):
        if self.path == '/jobs/42':
            if job['state'] == 'complete':
                self.reply(303, headers={'Location': '/results/42'})
            elif job['state'] == 'pending':
                self.reply(200, b'{"state":"pending"}', {'Content-Type': 'application/json'})
            else:
                self.reply(404)
        elif self.path == '/results/42' and job['state'] == 'complete':
            self.reply(200, b'{"value":7}', {'Content-Type': 'application/json'})
        elif self.path == '/asset':
            if self.headers.get('If-None-Match') == '"v1"':
                self.reply(304, headers={'ETag': '"v1"'})
            else:
                self.reply(200, b'artifact', {'ETag': '"v1"'})
        else:
            self.reply(404)

    def log_message(self, *args):
        pass


server = ThreadingHTTPServer(('127.0.0.1', 0), Handler)
thread = Thread(target=server.serve_forever)
thread.start()
try:
    responses = []

    def request(method, path, headers=None):
        connection = HTTPConnection('127.0.0.1', server.server_port, timeout=5)
        try:
            connection.request(method, path, headers=headers or {})
            response = connection.getresponse()
            value = {'status': response.status, 'body': response.read().decode(),
                     'location': response.getheader('Location'), 'etag': response.getheader('ETag')}
            responses.append(value)
            return value
        finally:
            connection.close()

    accepted = request('POST', '/jobs')
    assert accepted['status'] == 202 and job['state'] == 'pending'
    assert request('GET', '/results/42')['status'] == 404
    assert json.loads(request('GET', accepted['location'])['body']) == {'state': 'pending'}
    job['state'] = 'complete'  # Explicit fixture transition, not a background worker.
    redirect = request('GET', '/jobs/42')
    assert redirect['status'] == 303 and redirect['location'] == '/results/42'
    assert json.loads(request('GET', redirect['location'])['body']) == {'value': 7}
    cached = request('GET', '/asset')
    validation = request('GET', '/asset', {'If-None-Match': cached['etag']})
    assert validation['status'] == 304 and validation['body'] == '' and validation['location'] is None
    assert cached['body'] == 'artifact'
    print(json.dumps({'responses': responses, 'reuse_cached_body': cached['body']}))
finally:
    server.shutdown()
    thread.join()
    server.server_close()

POST 首先返回二零二,任务此时仍为等待状态;结果地址仍返回四零四,读取状态资源则返回二百和 pending 表示。之后脚本显式把内存状态改成完成,这不是已经测试过的后台工作进程。状态端点随即返回三零三,客户端再读取 Location 指向的位置,取得数值为七的结果。每个数字描述的都是当前响应,并没有提前承诺后面的步骤一定成功。

固定资源首次 GET 返回 artifact 与 ETag "v1"。客户端把验证器带回去后,得到没有内容、也没有 Location 的三零四,同时保留之前收到的字节用于复用。这展示响应契约,不意味着已经运行过浏览器缓存测试。实验只识别一个完全匹配的 If-None-Match 字符串,没有实现列表、通配符,也没有实现一般条件 GET 所要求的弱比较规则。初次表示与条件响应需要共同解释,不能只拿最后一条响应的正文是否为空来判断资源是否可用。

七个实际响应区分了接受处理、成功读取状态、跳转到结果,以及条件复用,没有证明任务持久入队或最终一定完成。审阅接口时,应把状态选择与响应头、内容和状态迁移放在一起检查。仅有一个看似合理的数字,既不能让未完成的业务自动完成,也不能让不符合契约的表示变成可用结果。

延伸阅读

来源