用请求截止时间预算串联网关与下游超时

举报
yd_217513249 发表于 2026/11/08 14:17:13 2026/11/08
【摘要】 请求截止时间若只在网关校验而不形成下游预算,慢依赖会拖到客户端放弃。本文用 FastAPI 中间件解析 X-Request-Deadline,计算剩余毫秒并按比例设置下游 HTTP 超时,剩余不足直接返回 504,并给出慢依赖模拟与验证步骤。

问题场景

网关收到请求后,如果只检查 X-Request-Deadline 是否存在,却不把剩余时间传给下游调用,就会出现一种常见错配:客户端已经接近超时,网关仍按固定超时等待慢依赖。结果是连接和协程被占用,客户端先断开,网关日志里只留下一个泛化错误。

更合理的做法是把截止时间当作预算:网关解析绝对毫秒时间戳,计算当前剩余毫秒;进入下游前扣除网关自身保留时间;再按比例把预算转换成 HTTP 客户端超时。剩余预算低于最小阈值时,不再发起下游调用,直接返回 504,并记录预算耗尽发生在哪个阶段、哪个依赖、预算和耗时是多少。

下面的示例用 FastAPI 实现网关中间件和下游调用封装,并用同进程的 /slow 端点模拟慢依赖。示例参数仅用于说明机制,不是通用最优值。

可运行的最小代码示例

保存为 main.py。

import asyncio
import json
import logging
import time

import httpx
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse

app = FastAPI()
logger = logging.getLogger('gateway')
logging.basicConfig(level=logging.INFO)

GATEWAY_MIN_REMAINING_MS = 30
GATEWAY_RESERVE_MS = 10
DOWNSTREAM_RATIO = 0.6
MIN_DOWNSTREAM_MS = 20


def now_ms():
    return int(time.time() * 1000)


class BudgetExhausted(Exception):
    def __init__(self, detail):
        super().__init__(detail.get('reason', 'budget_exhausted'))
        self.detail = detail


@app.middleware('http')
async def deadline_budget_middleware(request: Request, call_next):
    if not request.url.path.startswith('/gateway'):
        return await call_next(request)
    raw = request.headers.get('X-Request-Deadline')
    if not raw:
        return JSONResponse(status_code=400, content={'error': 'missing_deadline'})
    try:
        deadline_ms = int(raw)
    except ValueError:
        return JSONResponse(status_code=400, content={'error': 'invalid_deadline'})
    remaining_ms = deadline_ms - now_ms()
    if remaining_ms <= GATEWAY_MIN_REMAINING_MS:
        detail = {
            'stage': 'gateway',
            'reason': 'remaining_below_gateway_min',
            'remaining_ms': remaining_ms,
            'deadline_ms': deadline_ms,
        }
        logger.warning('budget_exhausted %s', json.dumps(detail, ensure_ascii=False))
        return JSONResponse(status_code=504, content={'error': 'deadline_exhausted', **detail})
    request.state.deadline_ms = deadline_ms
    request.state.remaining_ms = remaining_ms
    return await call_next(request)


async def call_downstream(request, url, name, ratio):
    deadline_ms = request.state.deadline_ms
    remaining_ms = deadline_ms - now_ms() - GATEWAY_RESERVE_MS
    if remaining_ms <= MIN_DOWNSTREAM_MS:
        detail = {
            'stage': 'downstream',
            'dependency': name,
            'reason': 'remaining_below_downstream_min',
            'remaining_ms': remaining_ms,
            'deadline_ms': deadline_ms,
        }
        logger.warning('budget_exhausted %s', json.dumps(detail, ensure_ascii=False))
        raise BudgetExhausted(detail)
    budget_ms = max(MIN_DOWNSTREAM_MS, int(remaining_ms * ratio))
    timeout = httpx.Timeout(budget_ms / 1000.0)
    started_ms = now_ms()
    try:
        async with httpx.AsyncClient(timeout=timeout) as client:
            response = await client.get(
                url,
                headers={'X-Request-Deadline': str(deadline_ms)},
            )
        response.raise_for_status()
        return response.json()
    except httpx.TimeoutException as exc:
        elapsed_ms = now_ms() - started_ms
        detail = {
            'stage': 'downstream',
            'dependency': name,
            'reason': 'downstream_timeout',
            'budget_ms': budget_ms,
            'elapsed_ms': elapsed_ms,
            'deadline_ms': deadline_ms,
        }
        logger.warning('budget_exhausted %s', json.dumps(detail, ensure_ascii=False))
        raise BudgetExhausted(detail) from exc


@app.get('/gateway/order')
async def gateway_order(request: Request):
    try:
        primary = await call_downstream(
            request,
            'http://127.0.0.1:8000/slow?delay_ms=120',
            'primary',
            DOWNSTREAM_RATIO,
        )
        secondary = await call_downstream(
            request,
            'http://127.0.0.1:8000/slow?delay_ms=30',
            'secondary',
            0.3,
        )
        return {'primary': primary, 'secondary': secondary}
    except BudgetExhausted as exc:
        return JSONResponse(
            status_code=504,
            content={'error': 'deadline_exhausted', **exc.detail},
        )


@app.get('/slow')
async def slow(delay_ms: int = 0):
    await asyncio.sleep(max(0, delay_ms) / 1000.0)
    return {'ok': True, 'delay_ms': delay_ms}

运行方法

安装依赖并启动服务:

pip install fastapi uvicorn httpx
uvicorn main:app --port 8000

另开一个终端,用标准库脚本构造不同预算验证。offset_ms 分别模拟预算充足、网关剩余不足、下游超时三种情况:

python - <<'PY'
import time
import urllib.error
import urllib.request

for offset_ms in (1000, 20, 150):
    deadline = int(time.time() * 1000) + offset_ms
    req = urllib.request.Request(
        'http://127.0.0.1:8000/gateway/order',
        headers={'X-Request-Deadline': str(deadline)},
    )
    try:
        with urllib.request.urlopen(req, timeout=5) as resp:
            print(offset_ms, resp.status, resp.read().decode())
    except urllib.error.HTTPError as exc:
        print(offset_ms, exc.code, exc.read().decode())
PY

预期结果:预算约 1000ms 时能返回 200;预算约 20ms 时中间件直接返回 504,原因包含 remaining_below_gateway_min;预算约 150ms 时主依赖分配到的超时低于其 120ms 延迟,返回 504,原因包含 downstream_timeout。日志中应能看到 budget_exhausted 及对应 JSON 字段。

边界与限制

X-Request-Deadline 使用绝对毫秒时间戳,依赖客户端与服务端时钟同步。跨主机部署时应使用统一时间源;同时要限制可接受的最大截止时间,避免异常大值绕过预算约束。示例未加入最大上限。

比例分配只是演示。真实系统中,关键依赖和非关键依赖应有不同权重;并行调用时需要为每个依赖单独计算预算,不能把同一份剩余时间重复分配。顺序调用则会自然累计消耗预算,后调用的可用时间会变少。

HTTP 客户端超时能覆盖连接、读取等阶段,但 DNS 解析、连接池等待和事件循环调度仍可能让实际耗时略超预算。504 表示预算不足或下游超时,不等同于业务失败,调用方仍需结合幂等、重试和降级策略处理。示例把慢依赖和网关放在同一进程同一端口,仅用于验证预算逻辑;生产环境应独立部署,并持续采集日志和指标。

【版权声明】本文为华为云社区用户原创内容,未经允许不得转载,如需转载请自行联系原作者进行授权。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0)

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。