← 中文资源

简体中文版本

API 弃用

API 弃用是接口或字段按计划退出的过程,通过时间表、遥测与迁移指引,在移除前给调用方留出足够窗口。

阅读英文原文

弃用是生命周期信号

弃用表示契约已进入退出计划,并不等于立即删除。接口在迁移期内继续可用,只有到了公告日期并复核剩余流量后才真正下线。这与删除不同,也与版本化不同:版本化建立并行契约,弃用则结束一个契约。

工作机制

在每次响应中提供机器可读信号。Deprecation 响应头按 RFC 9745 使用结构化 Unix 时间戳,例如 Deprecation: @1788307200;Sunset 响应头按 RFC 8594 给出实际移除时间,例如 Sunset: Wed, 02 Sep 2026 00:00:00 GMT。再用 Link 指向迁移指南,让调用方从响应本身就能找到替代方案。

同时按调用方维度统计旧路由流量,而不是只看总量:哪些 API Key、OAuth 客户端或 SDK 版本仍在调用。对已登记的集成负责人直接通知,并在下线前安排短时灰度停服,在仍可回滚时暴露隐藏调用方。

何时使用与取舍

当字段、端点、认证方式或整个版本必须消失时使用:语义不兼容、协议不安全,或维护成本已超过收益。旧客户端本就会忽略的增量变更通常不需要弃用,交给常规版本演进即可。

迁移窗口越长,破坏越小,但双份维护、重复测试和文档成本越高;窗口越短,成本越低,却把风险转嫁给按季度发布的调用方。响应头对机器客户端有用,但人看不到,也可能被代理剥离,因此只能补充而不能替代人工通知。

示例与常见误解

某团队只在更新日志里宣布 v1 下线,却从未统计使用量。到了下线日仍有 12% 的生产流量,移除立刻变成故障。更稳妥的顺序是:响应头、仪表盘、直接通知、灰度停服,最后返回带迁移链接的 410。

常见误解是把弃用日期当成必须删除的承诺。它承诺的是停止支持,删除前仍需复核真实流量。相关概念:API 版本管理、向后兼容、语义化版本。

代码示例

import hashlib
import time
from dataclasses import dataclass
from typing import Any, Dict, Optional


class IdempotencyConflictError(ValueError):
    """Raised when a key is reused with a different payload."""


@dataclass(frozen=True)
class Entry:
    payload_hash: str
    response: Any
    stored_at: float


def payload_fingerprint(payload: str) -> str:
    """Return a stable fingerprint for a request payload."""
    return hashlib.sha256(payload.encode("utf-8")).hexdigest()


class IdempotencyStore:
    """Replay recorded responses for duplicate keys; reject payload conflicts."""

    def __init__(self, ttl_s: float = 3600.0, clock: Any = time.monotonic) -> None:
        if ttl_s <= 0:
            raise ValueError("ttl_s must be positive")
        self._ttl_s = ttl_s
        self._clock = clock
        self._entries: Dict[str, Entry] = {}

    def execute(self, key: str, payload: str, produce: Any) -> Any:
        """Return the recorded response, or run produce and record it."""
        if not key:
            raise ValueError("idempotency key must be non-empty")
        fingerprint = payload_fingerprint(payload)
        existing = self._get_live(key)
        if existing is not None:
            if existing.payload_hash != fingerprint:
                raise IdempotencyConflictError(
                    f"key {key!r} was already used with a different payload"
                )
            return existing.response
        response = produce()
        self._entries[key] = Entry(
            payload_hash=fingerprint,
            response=response,
            stored_at=self._clock(),
        )
        return response

    def _get_live(self, key: str) -> Optional[Entry]:
        entry = self._entries.get(key)
        if entry is None:
            return None
        if self._clock() - entry.stored_at > self._ttl_s:
            del self._entries[key]
            return None
        return entry
use std::collections::HashMap;
use std::sync::Arc;

use axum::{
    extract::{Path, Query, State},
    http::StatusCode,
    response::{IntoResponse, Response},
    routing::get,
    Json, Router,
};
use serde::{Deserialize, Serialize};

#[derive(Clone, Debug, Serialize)]
struct Item {
    id: u32,
    name: String,
    price_cents: u32,
}

#[derive(Debug, Deserialize)]
struct ListQuery {
    limit: Option<usize>,
    offset: Option<usize>,
}

#[derive(Debug, Serialize)]
struct ErrorBody {
    error: String,
}

#[derive(Debug)]
enum ApiError {
    NotFound(u32),
    BadRequest(String),
}

impl IntoResponse for ApiError {
    fn into_response(self) -> Response {
        let (status, message) = match self {
            ApiError::NotFound(id) => (StatusCode::NOT_FOUND, format!("item {id} not found")),
            ApiError::BadRequest(message) => (StatusCode::BAD_REQUEST, message),
        };
        (status, Json(ErrorBody { error: message })).into_response()
    }
}

type AppState = Arc<HashMap<u32, Item>>;

async fn get_item(
    State(items): State<AppState>,
    Path(id): Path<u32>,
) -> Result<Json<Item>, ApiError> {
    items
        .get(&id)
        .cloned()
        .map(Json)
        .ok_or(ApiError::NotFound(id))
}

async fn list_items(
    State(items): State<AppState>,
    Query(query): Query<ListQuery>,
) -> Result<Json<Vec<Item>>, ApiError> {
    let limit = query.limit.unwrap_or(10);
    if !(1..=50).contains(&limit) {
        return Err(ApiError::BadRequest("limit must be between 1 and 50".to_string()));
    }
    let offset = query.offset.unwrap_or(0);

    let mut all: Vec<Item> = items.values().cloned().collect();
    all.sort_by_key(|item| item.id);
    Ok(Json(all.into_iter().skip(offset).take(limit).collect()))
}

#[tokio::main]
async fn main() {
    let mut catalog = HashMap::new();
    catalog.insert(1, Item { id: 1, name: "Keyboard".to_string(), price_cents: 4_900 });
    catalog.insert(2, Item { id: 2, name: "Mouse".to_string(), price_cents: 2_500 });

    let app = Router::new()
        .route("/items", get(list_items))
        .route("/items/{id}", get(get_item))
        .with_state(Arc::new(catalog));

    let listener = tokio::net::TcpListener::bind("127.0.0.1:3000").await.unwrap();
    println!("listening on http://{}", listener.local_addr().unwrap());
    axum::serve(listener, app).await.unwrap();
}