mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 09:20:22 +08:00
perf(transport): 全链路传输压缩优化
- 上游请求启用 HTTP/2 (HPACK 头部压缩 + 多路复用),添加 h2 依赖 - 上游请求体超过阈值时自动 gzip 压缩,使用紧凑 JSON 序列化 - 添加 brotli 依赖,Accept-Encoding 支持 gzip/deflate/br - 隧道帧压缩: Rust 端响应帧和 Python 端请求/响应帧均支持 gzip - Rust 端压缩/解压逻辑统一提取到 protocol.rs - Rust 端请求头构建改用 .headers() 替换 reqwest 默认值 - 新增 ENABLE_HTTP2、ENABLE_REQUEST_COMPRESSION 等环境变量配置
This commit is contained in:
@@ -14,7 +14,7 @@ use tracing::{debug, error, info, warn};
|
|||||||
use crate::state::{AppState, ServerContext};
|
use crate::state::{AppState, ServerContext};
|
||||||
|
|
||||||
use super::heartbeat::HeartbeatHandle;
|
use super::heartbeat::HeartbeatHandle;
|
||||||
use super::protocol::{Frame, MsgType, RequestMeta};
|
use super::protocol::{decompress_if_gzip, Frame, MsgType, RequestMeta};
|
||||||
use super::stream_handler;
|
use super::stream_handler;
|
||||||
use super::writer::FrameSender;
|
use super::writer::FrameSender;
|
||||||
|
|
||||||
@@ -92,8 +92,15 @@ where
|
|||||||
|
|
||||||
match frame.msg_type {
|
match frame.msg_type {
|
||||||
MsgType::RequestHeaders => {
|
MsgType::RequestHeaders => {
|
||||||
// Parse request metadata
|
// Decompress if the frame is gzip-compressed, then parse metadata
|
||||||
let meta: RequestMeta = match serde_json::from_slice(&frame.payload) {
|
let payload = match decompress_if_gzip(&frame) {
|
||||||
|
Ok(p) => p,
|
||||||
|
Err(e) => {
|
||||||
|
warn!(stream_id = frame.stream_id, error = %e, "frame decompress failed");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let meta: RequestMeta = match serde_json::from_slice(&payload) {
|
||||||
Ok(m) => m,
|
Ok(m) => m,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
warn!(stream_id = frame.stream_id, error = %e, "invalid request metadata");
|
warn!(stream_id = frame.stream_id, error = %e, "invalid request metadata");
|
||||||
|
|||||||
@@ -159,3 +159,53 @@ pub struct ResponseMeta {
|
|||||||
/// Header list preserving duplicates (e.g. multiple Set-Cookie).
|
/// Header list preserving duplicates (e.g. multiple Set-Cookie).
|
||||||
pub headers: Vec<(String, String)>,
|
pub headers: Vec<(String, String)>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Tunnel frame compression helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Minimum payload size to attempt gzip compression (bytes).
|
||||||
|
const COMPRESS_MIN_SIZE: usize = 512;
|
||||||
|
|
||||||
|
/// If the frame has the GZIP_COMPRESSED flag, decompress the payload; otherwise
|
||||||
|
/// return a clone of the raw payload bytes.
|
||||||
|
pub fn decompress_if_gzip(frame: &Frame) -> Result<Bytes, std::io::Error> {
|
||||||
|
if frame.is_gzip() {
|
||||||
|
decompress_gzip(&frame.payload)
|
||||||
|
} else {
|
||||||
|
Ok(frame.payload.clone())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Gzip-compress `data` if it is large enough and compression actually shrinks
|
||||||
|
/// the payload. Returns `(payload, extra_flags)` where `extra_flags` contains
|
||||||
|
/// `GZIP_COMPRESSED` when compression was applied.
|
||||||
|
pub fn compress_payload(data: Bytes) -> (Bytes, u8) {
|
||||||
|
if data.len() >= COMPRESS_MIN_SIZE {
|
||||||
|
if let Ok(compressed) = compress_gzip(&data) {
|
||||||
|
if compressed.len() < data.len() {
|
||||||
|
return (compressed, flags::GZIP_COMPRESSED);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(data, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn decompress_gzip(data: &[u8]) -> Result<Bytes, std::io::Error> {
|
||||||
|
use flate2::read::GzDecoder;
|
||||||
|
use std::io::Read;
|
||||||
|
let mut decoder = GzDecoder::new(data);
|
||||||
|
let mut buf = Vec::new();
|
||||||
|
decoder.read_to_end(&mut buf)?;
|
||||||
|
Ok(Bytes::from(buf))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn compress_gzip(data: &[u8]) -> Result<Bytes, std::io::Error> {
|
||||||
|
use flate2::write::GzEncoder;
|
||||||
|
use flate2::Compression;
|
||||||
|
use std::io::Write;
|
||||||
|
let mut encoder = GzEncoder::new(Vec::new(), Compression::fast());
|
||||||
|
encoder.write_all(data)?;
|
||||||
|
let compressed = encoder.finish()?;
|
||||||
|
Ok(Bytes::from(compressed))
|
||||||
|
}
|
||||||
|
|||||||
@@ -15,7 +15,9 @@ use tracing::{debug, warn};
|
|||||||
use crate::state::{AppState, ServerContext};
|
use crate::state::{AppState, ServerContext};
|
||||||
use crate::target_filter;
|
use crate::target_filter;
|
||||||
|
|
||||||
use super::protocol::{flags, Frame, MsgType, RequestMeta, ResponseMeta};
|
use super::protocol::{
|
||||||
|
compress_payload, decompress_if_gzip, flags, Frame, MsgType, RequestMeta, ResponseMeta,
|
||||||
|
};
|
||||||
use super::writer::FrameSender;
|
use super::writer::FrameSender;
|
||||||
|
|
||||||
/// Maximum response body chunk size per frame (32 KB).
|
/// Maximum response body chunk size per frame (32 KB).
|
||||||
@@ -95,8 +97,7 @@ async fn handle_stream_inner(
|
|||||||
match body_rx.recv().await {
|
match body_rx.recv().await {
|
||||||
Some(frame) => {
|
Some(frame) => {
|
||||||
if frame.msg_type == MsgType::RequestBody {
|
if frame.msg_type == MsgType::RequestBody {
|
||||||
let payload = if frame.is_gzip() {
|
let payload = match decompress_if_gzip(&frame) {
|
||||||
match decompress_gzip(&frame.payload) {
|
|
||||||
Ok(d) => d,
|
Ok(d) => d,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
send_error(
|
send_error(
|
||||||
@@ -107,9 +108,6 @@ async fn handle_stream_inner(
|
|||||||
.await;
|
.await;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
} else {
|
|
||||||
frame.payload.clone()
|
|
||||||
};
|
};
|
||||||
if !payload.is_empty() {
|
if !payload.is_empty() {
|
||||||
body_parts.push(payload);
|
body_parts.push(payload);
|
||||||
@@ -194,21 +192,23 @@ async fn handle_stream_inner(
|
|||||||
let timeout = Duration::from_secs(meta.timeout.clamp(MIN_TIMEOUT_SECS, MAX_TIMEOUT_SECS));
|
let timeout = Duration::from_secs(meta.timeout.clamp(MIN_TIMEOUT_SECS, MAX_TIMEOUT_SECS));
|
||||||
|
|
||||||
let method: reqwest::Method = meta.method.parse().unwrap_or(reqwest::Method::GET);
|
let method: reqwest::Method = meta.method.parse().unwrap_or(reqwest::Method::GET);
|
||||||
let mut req = client.request(method, &meta.url);
|
// Build a complete HeaderMap from tunnel headers, then set it all at once
|
||||||
|
// via .headers() which *replaces* reqwest defaults (e.g. Accept: */*),
|
||||||
|
// ensuring upstream sees exactly what Aether server intended.
|
||||||
|
let mut header_map = reqwest::header::HeaderMap::with_capacity(meta.headers.len());
|
||||||
for (k, v) in &meta.headers {
|
for (k, v) in &meta.headers {
|
||||||
let k_lower = k.to_ascii_lowercase();
|
let k_lower = k.to_ascii_lowercase();
|
||||||
// Skip hop-by-hop and security-sensitive headers
|
|
||||||
if BLOCKED_HEADERS.contains(&k_lower.as_str()) {
|
if BLOCKED_HEADERS.contains(&k_lower.as_str()) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
// Validate header name/value are valid HTTP
|
|
||||||
if let (Ok(name), Ok(value)) = (
|
if let (Ok(name), Ok(value)) = (
|
||||||
reqwest::header::HeaderName::from_bytes(k.as_bytes()),
|
reqwest::header::HeaderName::from_bytes(k.as_bytes()),
|
||||||
reqwest::header::HeaderValue::from_str(v),
|
reqwest::header::HeaderValue::from_str(v),
|
||||||
) {
|
) {
|
||||||
req = req.header(name, value);
|
header_map.insert(name, value);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
let mut req = client.request(method, &meta.url).headers(header_map);
|
||||||
let body_size = body.len();
|
let body_size = body.len();
|
||||||
if !body.is_empty() {
|
if !body.is_empty() {
|
||||||
req = req.body(body);
|
req = req.body(body);
|
||||||
@@ -258,39 +258,51 @@ async fn handle_stream_inner(
|
|||||||
status,
|
status,
|
||||||
headers: resp_headers,
|
headers: resp_headers,
|
||||||
};
|
};
|
||||||
let meta_json = serde_json::to_vec(&resp_meta).unwrap_or_default();
|
let meta_json: Bytes = serde_json::to_vec(&resp_meta).unwrap_or_default().into();
|
||||||
|
let (meta_payload, meta_flags) = compress_payload(meta_json);
|
||||||
if !send_frame(
|
if !send_frame(
|
||||||
frame_tx,
|
frame_tx,
|
||||||
Frame::new(stream_id, MsgType::ResponseHeaders, 0, meta_json),
|
Frame::new(
|
||||||
|
stream_id,
|
||||||
|
MsgType::ResponseHeaders,
|
||||||
|
meta_flags,
|
||||||
|
meta_payload,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stream response body
|
// Stream response body — relay upstream bytes through the tunnel.
|
||||||
|
// Apply tunnel-level frame compression for chunks that benefit from it
|
||||||
|
// (e.g. uncompressed SSE text). Already-compressed data (gzip/br from
|
||||||
|
// upstream Content-Encoding) won't shrink further and will be sent as-is
|
||||||
|
// thanks to the size check in compress_payload().
|
||||||
let mut stream = response.bytes_stream();
|
let mut stream = response.bytes_stream();
|
||||||
while let Some(chunk_result) = stream.next().await {
|
while let Some(chunk_result) = stream.next().await {
|
||||||
match chunk_result {
|
match chunk_result {
|
||||||
Ok(chunk) => {
|
Ok(chunk) => {
|
||||||
if chunk.len() <= MAX_CHUNK_SIZE {
|
if chunk.len() <= MAX_CHUNK_SIZE {
|
||||||
|
let (payload, extra_flags) = compress_payload(chunk);
|
||||||
if !send_frame(
|
if !send_frame(
|
||||||
frame_tx,
|
frame_tx,
|
||||||
Frame::new(stream_id, MsgType::ResponseBody, 0, chunk),
|
Frame::new(stream_id, MsgType::ResponseBody, extra_flags, payload),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Split oversized chunks
|
// Split oversized chunks, compress each slice
|
||||||
let mut offset = 0;
|
let mut offset = 0;
|
||||||
while offset < chunk.len() {
|
while offset < chunk.len() {
|
||||||
let end = (offset + MAX_CHUNK_SIZE).min(chunk.len());
|
let end = (offset + MAX_CHUNK_SIZE).min(chunk.len());
|
||||||
let slice = chunk.slice(offset..end);
|
let slice = chunk.slice(offset..end);
|
||||||
|
let (payload, extra_flags) = compress_payload(slice);
|
||||||
if !send_frame(
|
if !send_frame(
|
||||||
frame_tx,
|
frame_tx,
|
||||||
Frame::new(stream_id, MsgType::ResponseBody, 0, slice),
|
Frame::new(stream_id, MsgType::ResponseBody, extra_flags, payload),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
@@ -337,12 +349,3 @@ async fn send_error(tx: &FrameSender, stream_id: u32, msg: &str) {
|
|||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
|
|
||||||
fn decompress_gzip(data: &[u8]) -> Result<Bytes, std::io::Error> {
|
|
||||||
use flate2::read::GzDecoder;
|
|
||||||
use std::io::Read;
|
|
||||||
let mut decoder = GzDecoder::new(data);
|
|
||||||
let mut buf = Vec::new();
|
|
||||||
decoder.read_to_end(&mut buf)?;
|
|
||||||
Ok(Bytes::from(buf))
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -27,7 +27,8 @@ dependencies = [
|
|||||||
"pydantic>=2.12.5",
|
"pydantic>=2.12.5",
|
||||||
"python-dotenv>=1.2.1",
|
"python-dotenv>=1.2.1",
|
||||||
"openai>=2.16.0",
|
"openai>=2.16.0",
|
||||||
"httpx[socks]>=0.28.1",
|
"httpx[socks,http2]>=0.28.1",
|
||||||
|
"brotli>=1.1.0",
|
||||||
"sqlalchemy>=2.0.46",
|
"sqlalchemy>=2.0.46",
|
||||||
"alembic>=1.18.3",
|
"alembic>=1.18.3",
|
||||||
"bcrypt>=5.0.0",
|
"bcrypt>=5.0.0",
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ class HTTPClientPool:
|
|||||||
# 双重检查,避免重复创建
|
# 双重检查,避免重复创建
|
||||||
if cls._default_client is None:
|
if cls._default_client is None:
|
||||||
cls._default_client = httpx.AsyncClient(
|
cls._default_client = httpx.AsyncClient(
|
||||||
http2=False, # 暂时禁用HTTP/2以提高兼容性
|
http2=config.enable_http2,
|
||||||
verify=get_ssl_context(), # 使用 certifi 证书
|
verify=get_ssl_context(), # 使用 certifi 证书
|
||||||
timeout=httpx.Timeout(
|
timeout=httpx.Timeout(
|
||||||
connect=config.http_connect_timeout,
|
connect=config.http_connect_timeout,
|
||||||
@@ -106,7 +106,7 @@ class HTTPClientPool:
|
|||||||
"""
|
"""
|
||||||
if cls._default_client is None:
|
if cls._default_client is None:
|
||||||
cls._default_client = httpx.AsyncClient(
|
cls._default_client = httpx.AsyncClient(
|
||||||
http2=False, # 暂时禁用HTTP/2以提高兼容性
|
http2=config.enable_http2,
|
||||||
verify=get_ssl_context(), # 使用 certifi 证书
|
verify=get_ssl_context(), # 使用 certifi 证书
|
||||||
timeout=httpx.Timeout(
|
timeout=httpx.Timeout(
|
||||||
connect=config.http_connect_timeout,
|
connect=config.http_connect_timeout,
|
||||||
@@ -143,7 +143,7 @@ class HTTPClientPool:
|
|||||||
if name not in cls._clients:
|
if name not in cls._clients:
|
||||||
# 合并默认配置和自定义配置
|
# 合并默认配置和自定义配置
|
||||||
default_config = {
|
default_config = {
|
||||||
"http2": False,
|
"http2": config.enable_http2,
|
||||||
"verify": get_ssl_context(),
|
"verify": get_ssl_context(),
|
||||||
"timeout": httpx.Timeout(
|
"timeout": httpx.Timeout(
|
||||||
connect=config.http_connect_timeout,
|
connect=config.http_connect_timeout,
|
||||||
@@ -282,7 +282,7 @@ class HTTPClientPool:
|
|||||||
|
|
||||||
# 创建新客户端(使用默认超时,请求时可覆盖)
|
# 创建新客户端(使用默认超时,请求时可覆盖)
|
||||||
client_config: dict[str, Any] = {
|
client_config: dict[str, Any] = {
|
||||||
"http2": False,
|
"http2": config.enable_http2,
|
||||||
"verify": get_ssl_context_for_profile(tls_profile),
|
"verify": get_ssl_context_for_profile(tls_profile),
|
||||||
"follow_redirects": True,
|
"follow_redirects": True,
|
||||||
"limits": httpx.Limits(
|
"limits": httpx.Limits(
|
||||||
@@ -377,7 +377,7 @@ class HTTPClientPool:
|
|||||||
response = await client.get('https://example.com')
|
response = await client.get('https://example.com')
|
||||||
"""
|
"""
|
||||||
default_config = {
|
default_config = {
|
||||||
"http2": False,
|
"http2": config.enable_http2,
|
||||||
"verify": get_ssl_context(),
|
"verify": get_ssl_context(),
|
||||||
"timeout": httpx.Timeout(
|
"timeout": httpx.Timeout(
|
||||||
connect=config.http_connect_timeout,
|
connect=config.http_connect_timeout,
|
||||||
@@ -416,7 +416,7 @@ class HTTPClientPool:
|
|||||||
配置好的 httpx.AsyncClient 实例(调用者需要负责关闭)
|
配置好的 httpx.AsyncClient 实例(调用者需要负责关闭)
|
||||||
"""
|
"""
|
||||||
client_config: dict[str, Any] = {
|
client_config: dict[str, Any] = {
|
||||||
"http2": False,
|
"http2": config.enable_http2,
|
||||||
"verify": get_ssl_context_for_profile(tls_profile),
|
"verify": get_ssl_context_for_profile(tls_profile),
|
||||||
"follow_redirects": True,
|
"follow_redirects": True,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -177,6 +177,22 @@ class Config:
|
|||||||
)
|
)
|
||||||
self.http_keepalive_expiry = float(os.getenv("HTTP_KEEPALIVE_EXPIRY", "30.0"))
|
self.http_keepalive_expiry = float(os.getenv("HTTP_KEEPALIVE_EXPIRY", "30.0"))
|
||||||
|
|
||||||
|
# 上游传输优化配置
|
||||||
|
# ENABLE_HTTP2: 是否对上游请求启用 HTTP/2(HPACK 头部压缩 + 多路复用)
|
||||||
|
# - 三家上游(Claude/OpenAI/Gemini)均已确认支持 HTTP/2
|
||||||
|
# - 出现兼容性问题时可通过环境变量快速回退到 HTTP/1.1
|
||||||
|
self.enable_http2 = os.getenv("ENABLE_HTTP2", "true").lower() == "true"
|
||||||
|
# ENABLE_REQUEST_COMPRESSION: 是否对上游请求体启用 gzip 压缩
|
||||||
|
# - 仅对超过 REQUEST_COMPRESSION_MIN_SIZE 的请求体生效
|
||||||
|
# - 上游 Cloudflare/Google Front End 层会透明解压
|
||||||
|
self.enable_request_compression = (
|
||||||
|
os.getenv("ENABLE_REQUEST_COMPRESSION", "true").lower() == "true"
|
||||||
|
)
|
||||||
|
# REQUEST_COMPRESSION_MIN_SIZE: 触发请求体压缩的最小字节数
|
||||||
|
# - gzip 有固定头部开销,小请求压缩后可能反而变大
|
||||||
|
# - 默认 1024 字节(1KB)
|
||||||
|
self.request_compression_min_size = int(os.getenv("REQUEST_COMPRESSION_MIN_SIZE", "1024"))
|
||||||
|
|
||||||
# 流式处理配置
|
# 流式处理配置
|
||||||
# STREAM_PREFETCH_LINES: 预读行数,用于检测嵌套错误
|
# STREAM_PREFETCH_LINES: 预读行数,用于检测嵌套错误
|
||||||
# STREAM_STATS_DELAY: 统计记录延迟(秒),等待流完全关闭
|
# STREAM_STATS_DELAY: 统计记录延迟(秒),等待流完全关闭
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ BROWSER_FINGERPRINT_HEADERS: dict[str, str] = {
|
|||||||
"Chrome/140.0.7339.249 Electron/38.7.0 Safari/537.36"
|
"Chrome/140.0.7339.249 Electron/38.7.0 Safari/537.36"
|
||||||
),
|
),
|
||||||
"Accept": "application/json",
|
"Accept": "application/json",
|
||||||
|
"Accept-Encoding": "gzip, deflate, br",
|
||||||
"Accept-Language": "zh-CN",
|
"Accept-Language": "zh-CN",
|
||||||
"sec-ch-ua": '"Not=A?Brand";v="24", "Chromium";v="140"',
|
"sec-ch-ua": '"Not=A?Brand";v="24", "Chromium";v="140"',
|
||||||
"sec-ch-ua-mobile": "?0",
|
"sec-ch-ua-mobile": "?0",
|
||||||
@@ -73,7 +74,7 @@ UPSTREAM_DROP_HEADERS: frozenset[str] = frozenset(
|
|||||||
"content-length",
|
"content-length",
|
||||||
"transfer-encoding",
|
"transfer-encoding",
|
||||||
"connection",
|
"connection",
|
||||||
# 编码头 - 避免客户端请求 brotli/zstd 但 httpx 不支持
|
# 编码头 - 丢弃客户端值,由 BROWSER_FINGERPRINT_HEADERS 统一设置
|
||||||
"accept-encoding",
|
"accept-encoding",
|
||||||
# 反向代理 / 网关注入的头部 - 属于本站基础设施,不应泄露给上游
|
# 反向代理 / 网关注入的头部 - 属于本站基础设施,不应泄露给上游
|
||||||
"x-real-ip",
|
"x-real-ip",
|
||||||
|
|||||||
@@ -7,13 +7,16 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import gzip
|
||||||
import hashlib
|
import hashlib
|
||||||
|
import json
|
||||||
import time
|
import time
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from urllib.parse import quote, urlparse
|
from urllib.parse import quote, urlparse
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
|
from src.config import config
|
||||||
from src.core.exceptions import ProxyNodeUnavailableError
|
from src.core.exceptions import ProxyNodeUnavailableError
|
||||||
from src.core.logger import logger
|
from src.core.logger import logger
|
||||||
|
|
||||||
@@ -614,6 +617,30 @@ def resolve_delegate_config(proxy_config: dict[str, Any] | None) -> dict[str, An
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _maybe_compress_payload(
|
||||||
|
payload: Any,
|
||||||
|
headers: dict[str, str],
|
||||||
|
) -> tuple[bytes, dict[str, str]]:
|
||||||
|
"""
|
||||||
|
将 payload 序列化为 JSON bytes,按配置决定是否 gzip 压缩。
|
||||||
|
|
||||||
|
NOTE: 使用紧凑分隔符 ``(",", ":")`` 序列化(无空格),相比 httpx ``json=``
|
||||||
|
参数的默认 ``json.dumps``(带空格分隔符)体积更小,所有上游 API 均兼容。
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(body_bytes, updated_headers)
|
||||||
|
"""
|
||||||
|
json_bytes = json.dumps(payload, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
|
||||||
|
|
||||||
|
if config.enable_request_compression and len(json_bytes) >= config.request_compression_min_size:
|
||||||
|
compressed = gzip.compress(json_bytes, compresslevel=6)
|
||||||
|
if len(compressed) < len(json_bytes):
|
||||||
|
headers = {**headers, "Content-Encoding": "gzip"}
|
||||||
|
return compressed, headers
|
||||||
|
|
||||||
|
return json_bytes, headers
|
||||||
|
|
||||||
|
|
||||||
def build_post_kwargs(
|
def build_post_kwargs(
|
||||||
_delegate_cfg: dict[str, Any] | None = None,
|
_delegate_cfg: dict[str, Any] | None = None,
|
||||||
*,
|
*,
|
||||||
@@ -631,10 +658,11 @@ def build_post_kwargs(
|
|||||||
``_delegate_cfg`` 和 ``refresh_auth`` 已废弃(tunnel 模式下认证由 transport 层处理),
|
``_delegate_cfg`` 和 ``refresh_auth`` 已废弃(tunnel 模式下认证由 transport 层处理),
|
||||||
保留仅为兼容现有调用方签名。
|
保留仅为兼容现有调用方签名。
|
||||||
"""
|
"""
|
||||||
|
content, final_headers = _maybe_compress_payload(payload, headers)
|
||||||
return {
|
return {
|
||||||
"url": url,
|
"url": url,
|
||||||
"json": payload,
|
"content": content,
|
||||||
"headers": headers,
|
"headers": final_headers,
|
||||||
"timeout": httpx.Timeout(timeout),
|
"timeout": httpx.Timeout(timeout),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -655,11 +683,12 @@ def build_stream_kwargs(
|
|||||||
|
|
||||||
``_delegate_cfg`` 已废弃,保留仅为兼容现有调用方签名。
|
``_delegate_cfg`` 已废弃,保留仅为兼容现有调用方签名。
|
||||||
"""
|
"""
|
||||||
|
content, final_headers = _maybe_compress_payload(payload, headers)
|
||||||
kwargs: dict[str, Any] = {
|
kwargs: dict[str, Any] = {
|
||||||
"method": "POST",
|
"method": "POST",
|
||||||
"url": url,
|
"url": url,
|
||||||
"json": payload,
|
"content": content,
|
||||||
"headers": headers,
|
"headers": final_headers,
|
||||||
}
|
}
|
||||||
if timeout is not None:
|
if timeout is not None:
|
||||||
kwargs["timeout"] = httpx.Timeout(timeout)
|
kwargs["timeout"] = httpx.Timeout(timeout)
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ WebSocket 隧道管理器
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import gzip
|
||||||
import json
|
import json
|
||||||
import time
|
import time
|
||||||
from typing import TYPE_CHECKING, Any
|
from typing import TYPE_CHECKING, Any
|
||||||
@@ -21,6 +22,10 @@ from src.core.logger import logger
|
|||||||
|
|
||||||
from .tunnel_protocol import Frame, FrameFlags, MsgType
|
from .tunnel_protocol import Frame, FrameFlags, MsgType
|
||||||
|
|
||||||
|
# 隧道帧压缩的最小 payload 大小(字节)
|
||||||
|
# 小于此值的帧压缩收益不大,反而增加 CPU 开销
|
||||||
|
_TUNNEL_COMPRESS_MIN_SIZE = 512
|
||||||
|
|
||||||
|
|
||||||
class TunnelConnection:
|
class TunnelConnection:
|
||||||
"""单条 tunnel 连接"""
|
"""单条 tunnel 连接"""
|
||||||
@@ -309,7 +314,7 @@ class TunnelManager:
|
|||||||
stream_state = conn.create_stream(stream_id)
|
stream_state = conn.create_stream(stream_id)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# 发送 REQUEST_HEADERS
|
# 发送 REQUEST_HEADERS(大元数据帧压缩)
|
||||||
meta = json.dumps(
|
meta = json.dumps(
|
||||||
{
|
{
|
||||||
"method": method,
|
"method": method,
|
||||||
@@ -318,13 +323,19 @@ class TunnelManager:
|
|||||||
"timeout": int(timeout),
|
"timeout": int(timeout),
|
||||||
}
|
}
|
||||||
).encode()
|
).encode()
|
||||||
await conn.send_frame(Frame(stream_id, MsgType.REQUEST_HEADERS, 0, meta))
|
meta_payload, meta_flags = _compress_frame_payload(meta)
|
||||||
|
|
||||||
# 发送 REQUEST_BODY + END_STREAM
|
|
||||||
body_data = body or b""
|
|
||||||
await conn.send_frame(
|
await conn.send_frame(
|
||||||
Frame(stream_id, MsgType.REQUEST_BODY, FrameFlags.END_STREAM, body_data)
|
Frame(stream_id, MsgType.REQUEST_HEADERS, meta_flags, meta_payload)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# 发送 REQUEST_BODY + END_STREAM(大请求体帧压缩)
|
||||||
|
body_data = body or b""
|
||||||
|
if body_data:
|
||||||
|
body_payload, body_flags = _compress_frame_payload(body_data)
|
||||||
|
else:
|
||||||
|
body_payload, body_flags = body_data, 0
|
||||||
|
body_flags |= FrameFlags.END_STREAM
|
||||||
|
await conn.send_frame(Frame(stream_id, MsgType.REQUEST_BODY, body_flags, body_payload))
|
||||||
except Exception:
|
except Exception:
|
||||||
conn.remove_stream(stream_id)
|
conn.remove_stream(stream_id)
|
||||||
raise
|
raise
|
||||||
@@ -348,14 +359,16 @@ class TunnelManager:
|
|||||||
if not stream:
|
if not stream:
|
||||||
return
|
return
|
||||||
try:
|
try:
|
||||||
meta = json.loads(frame.payload)
|
payload = _decompress_frame_payload(frame)
|
||||||
|
meta = json.loads(payload)
|
||||||
stream.set_response_headers(meta["status"], meta.get("headers", []))
|
stream.set_response_headers(meta["status"], meta.get("headers", []))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
stream.set_error(f"invalid response headers: {e}")
|
stream.set_error(f"invalid response headers: {e}")
|
||||||
|
|
||||||
elif frame.msg_type == MsgType.RESPONSE_BODY:
|
elif frame.msg_type == MsgType.RESPONSE_BODY:
|
||||||
if stream:
|
if stream:
|
||||||
stream.push_body_chunk(frame.payload)
|
payload = _decompress_frame_payload(frame)
|
||||||
|
stream.push_body_chunk(payload)
|
||||||
|
|
||||||
elif frame.msg_type == MsgType.STREAM_END:
|
elif frame.msg_type == MsgType.STREAM_END:
|
||||||
if stream:
|
if stream:
|
||||||
@@ -426,6 +439,32 @@ class TunnelManager:
|
|||||||
logger.debug("heartbeat ACK send failed for node_id={}", conn.node_id)
|
logger.debug("heartbeat ACK send failed for node_id={}", conn.node_id)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 隧道帧压缩 / 解压
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _compress_frame_payload(data: bytes) -> tuple[bytes, int]:
|
||||||
|
"""按配置对帧 payload 进行 gzip 压缩。
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(payload, flags) — 若压缩则 flags 含 GZIP_COMPRESSED,否则 flags=0。
|
||||||
|
"""
|
||||||
|
if len(data) >= _TUNNEL_COMPRESS_MIN_SIZE:
|
||||||
|
compressed = gzip.compress(data, compresslevel=6)
|
||||||
|
# 仅在压缩确实缩小时使用
|
||||||
|
if len(compressed) < len(data):
|
||||||
|
return compressed, FrameFlags.GZIP_COMPRESSED
|
||||||
|
return data, 0
|
||||||
|
|
||||||
|
|
||||||
|
def _decompress_frame_payload(frame: Frame) -> bytes:
|
||||||
|
"""如果帧设置了 GZIP_COMPRESSED 标志则解压,否则原样返回。"""
|
||||||
|
if frame.is_gzip:
|
||||||
|
return gzip.decompress(frame.payload)
|
||||||
|
return frame.payload
|
||||||
|
|
||||||
|
|
||||||
# 全局单例
|
# 全局单例
|
||||||
_tunnel_manager: TunnelManager | None = None
|
_tunnel_manager: TunnelManager | None = None
|
||||||
|
|
||||||
|
|||||||
78
uv.lock
generated
78
uv.lock
generated
@@ -14,11 +14,12 @@ dependencies = [
|
|||||||
{ name = "apscheduler" },
|
{ name = "apscheduler" },
|
||||||
{ name = "asyncpg" },
|
{ name = "asyncpg" },
|
||||||
{ name = "bcrypt" },
|
{ name = "bcrypt" },
|
||||||
|
{ name = "brotli" },
|
||||||
{ name = "certifi" },
|
{ name = "certifi" },
|
||||||
{ name = "cryptography" },
|
{ name = "cryptography" },
|
||||||
{ name = "fastapi", extra = ["standard"] },
|
{ name = "fastapi", extra = ["standard"] },
|
||||||
{ name = "gunicorn" },
|
{ name = "gunicorn" },
|
||||||
{ name = "httpx", extra = ["socks"] },
|
{ name = "httpx", extra = ["http2", "socks"] },
|
||||||
{ name = "ldap3" },
|
{ name = "ldap3" },
|
||||||
{ name = "loguru" },
|
{ name = "loguru" },
|
||||||
{ name = "openai" },
|
{ name = "openai" },
|
||||||
@@ -70,13 +71,14 @@ requires-dist = [
|
|||||||
{ name = "apscheduler", specifier = ">=3.11.2" },
|
{ name = "apscheduler", specifier = ">=3.11.2" },
|
||||||
{ name = "asyncpg", specifier = ">=0.31.0" },
|
{ name = "asyncpg", specifier = ">=0.31.0" },
|
||||||
{ name = "bcrypt", specifier = ">=5.0.0" },
|
{ name = "bcrypt", specifier = ">=5.0.0" },
|
||||||
|
{ name = "brotli", specifier = ">=1.1.0" },
|
||||||
{ name = "certifi", specifier = ">=2026.1.4" },
|
{ name = "certifi", specifier = ">=2026.1.4" },
|
||||||
{ name = "cryptography", specifier = ">=46.0.4" },
|
{ name = "cryptography", specifier = ">=46.0.4" },
|
||||||
{ name = "curl-cffi", marker = "extra == 'tls'", specifier = ">=0.7.0" },
|
{ name = "curl-cffi", marker = "extra == 'tls'", specifier = ">=0.7.0" },
|
||||||
{ name = "fastapi", extras = ["standard"], specifier = ">=0.128.0" },
|
{ name = "fastapi", extras = ["standard"], specifier = ">=0.128.0" },
|
||||||
{ name = "gunicorn", specifier = ">=24.1.1" },
|
{ name = "gunicorn", specifier = ">=24.1.1" },
|
||||||
{ name = "httpx", marker = "extra == 'dev'", specifier = ">=0.25.0" },
|
{ name = "httpx", marker = "extra == 'dev'", specifier = ">=0.25.0" },
|
||||||
{ name = "httpx", extras = ["socks"], specifier = ">=0.28.1" },
|
{ name = "httpx", extras = ["http2", "socks"], specifier = ">=0.28.1" },
|
||||||
{ name = "ldap3", specifier = ">=2.9.1" },
|
{ name = "ldap3", specifier = ">=2.9.1" },
|
||||||
{ name = "loguru", specifier = ">=0.7.3" },
|
{ name = "loguru", specifier = ">=0.7.3" },
|
||||||
{ name = "openai", specifier = ">=2.16.0" },
|
{ name = "openai", specifier = ">=2.16.0" },
|
||||||
@@ -458,6 +460,44 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/e4/3d/51bdb3ecbfadfaf825ec0c75e1de6077422b4afa2091c6c9ba34fbfc0c2d/black-26.1.0-py3-none-any.whl", hash = "sha256:1054e8e47ebd686e078c0bb0eaf31e6ce69c966058d122f2c0c950311f9f3ede", size = 204010 },
|
{ url = "https://files.pythonhosted.org/packages/e4/3d/51bdb3ecbfadfaf825ec0c75e1de6077422b4afa2091c6c9ba34fbfc0c2d/black-26.1.0-py3-none-any.whl", hash = "sha256:1054e8e47ebd686e078c0bb0eaf31e6ce69c966058d122f2c0c950311f9f3ede", size = 204010 },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "brotli"
|
||||||
|
version = "1.2.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/f7/16/c92ca344d646e71a43b8bb353f0a6490d7f6e06210f8554c8f874e454285/brotli-1.2.0.tar.gz", hash = "sha256:e310f77e41941c13340a95976fe66a8a95b01e783d430eeaf7a2f87e0a57dd0a", size = 7388632 }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/11/ee/b0a11ab2315c69bb9b45a2aaed022499c9c24a205c3a49c3513b541a7967/brotli-1.2.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:35d382625778834a7f3061b15423919aa03e4f5da34ac8e02c074e4b75ab4f84", size = 861543 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e1/2f/29c1459513cd35828e25531ebfcbf3e92a5e49f560b1777a9af7203eb46e/brotli-1.2.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7a61c06b334bd99bc5ae84f1eeb36bfe01400264b3c352f968c6e30a10f9d08b", size = 444288 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3d/6f/feba03130d5fceadfa3a1bb102cb14650798c848b1df2a808356f939bb16/brotli-1.2.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:acec55bb7c90f1dfc476126f9711a8e81c9af7fb617409a9ee2953115343f08d", size = 1528071 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2b/38/f3abb554eee089bd15471057ba85f47e53a44a462cfce265d9bf7088eb09/brotli-1.2.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:260d3692396e1895c5034f204f0db022c056f9e2ac841593a4cf9426e2a3faca", size = 1626913 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/03/a7/03aa61fbc3c5cbf99b44d158665f9b0dd3d8059be16c460208d9e385c837/brotli-1.2.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:072e7624b1fc4d601036ab3f4f27942ef772887e876beff0301d261210bca97f", size = 1419762 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/21/1b/0374a89ee27d152a5069c356c96b93afd1b94eae83f1e004b57eb6ce2f10/brotli-1.2.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:adedc4a67e15327dfdd04884873c6d5a01d3e3b6f61406f99b1ed4865a2f6d28", size = 1484494 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/cf/57/69d4fe84a67aef4f524dcd075c6eee868d7850e85bf01d778a857d8dbe0a/brotli-1.2.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:7a47ce5c2288702e09dc22a44d0ee6152f2c7eda97b3c8482d826a1f3cfc7da7", size = 1593302 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d5/3b/39e13ce78a8e9a621c5df3aeb5fd181fcc8caba8c48a194cd629771f6828/brotli-1.2.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:af43b8711a8264bb4e7d6d9a6d004c3a2019c04c01127a868709ec29962b6036", size = 1487913 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/62/28/4d00cb9bd76a6357a66fcd54b4b6d70288385584063f4b07884c1e7286ac/brotli-1.2.0-cp312-cp312-win32.whl", hash = "sha256:e99befa0b48f3cd293dafeacdd0d191804d105d279e0b387a32054c1180f3161", size = 334362 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1c/4e/bc1dcac9498859d5e353c9b153627a3752868a9d5f05ce8dedd81a2354ab/brotli-1.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:b35c13ce241abdd44cb8ca70683f20c0c079728a36a996297adb5334adfc1c44", size = 369115 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6c/d4/4ad5432ac98c73096159d9ce7ffeb82d151c2ac84adcc6168e476bb54674/brotli-1.2.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:9e5825ba2c9998375530504578fd4d5d1059d09621a02065d1b6bfc41a8e05ab", size = 861523 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/91/9f/9cc5bd03ee68a85dc4bc89114f7067c056a3c14b3d95f171918c088bf88d/brotli-1.2.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0cf8c3b8ba93d496b2fae778039e2f5ecc7cff99df84df337ca31d8f2252896c", size = 444289 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2e/b6/fe84227c56a865d16a6614e2c4722864b380cb14b13f3e6bef441e73a85a/brotli-1.2.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c8565e3cdc1808b1a34714b553b262c5de5fbda202285782173ec137fd13709f", size = 1528076 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/55/de/de4ae0aaca06c790371cf6e7ee93a024f6b4bb0568727da8c3de112e726c/brotli-1.2.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:26e8d3ecb0ee458a9804f47f21b74845cc823fd1bb19f02272be70774f56e2a6", size = 1626880 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5f/16/a1b22cbea436642e071adcaf8d4b350a2ad02f5e0ad0da879a1be16188a0/brotli-1.2.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:67a91c5187e1eec76a61625c77a6c8c785650f5b576ca732bd33ef58b0dff49c", size = 1419737 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/46/63/c968a97cbb3bdbf7f974ef5a6ab467a2879b82afbc5ffb65b8acbb744f95/brotli-1.2.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4ecdb3b6dc36e6d6e14d3a1bdc6c1057c8cbf80db04031d566eb6080ce283a48", size = 1484440 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/06/9d/102c67ea5c9fc171f423e8399e585dabea29b5bc79b05572891e70013cdd/brotli-1.2.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3e1b35d56856f3ed326b140d3c6d9db91740f22e14b06e840fe4bb1923439a18", size = 1593313 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9e/4a/9526d14fa6b87bc827ba1755a8440e214ff90de03095cacd78a64abe2b7d/brotli-1.2.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:54a50a9dad16b32136b2241ddea9e4df159b41247b2ce6aac0b3276a66a8f1e5", size = 1487945 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5b/e8/3fe1ffed70cbef83c5236166acaed7bb9c766509b157854c80e2f766b38c/brotli-1.2.0-cp313-cp313-win32.whl", hash = "sha256:1b1d6a4efedd53671c793be6dd760fcf2107da3a52331ad9ea429edf0902f27a", size = 334368 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ff/91/e739587be970a113b37b821eae8097aac5a48e5f0eca438c22e4c7dd8648/brotli-1.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:b63daa43d82f0cdabf98dee215b375b4058cce72871fd07934f179885aad16e8", size = 369116 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/17/e1/298c2ddf786bb7347a1cd71d63a347a79e5712a7c0cba9e3c3458ebd976f/brotli-1.2.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:6c12dad5cd04530323e723787ff762bac749a7b256a5bece32b2243dd5c27b21", size = 863080 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/84/0c/aac98e286ba66868b2b3b50338ffbd85a35c7122e9531a73a37a29763d38/brotli-1.2.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:3219bd9e69868e57183316ee19c84e03e8f8b5a1d1f2667e1aa8c2f91cb061ac", size = 445453 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ec/f1/0ca1f3f99ae300372635ab3fe2f7a79fa335fee3d874fa7f9e68575e0e62/brotli-1.2.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:963a08f3bebd8b75ac57661045402da15991468a621f014be54e50f53a58d19e", size = 1528168 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d6/a6/2ebfc8f766d46df8d3e65b880a2e220732395e6d7dc312c1e1244b0f074a/brotli-1.2.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9322b9f8656782414b37e6af884146869d46ab85158201d82bab9abbcb971dc7", size = 1627098 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f3/2f/0976d5b097ff8a22163b10617f76b2557f15f0f39d6a0fe1f02b1a53e92b/brotli-1.2.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cf9cba6f5b78a2071ec6fb1e7bd39acf35071d90a81231d67e92d637776a6a63", size = 1419861 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9c/97/d76df7176a2ce7616ff94c1fb72d307c9a30d2189fe877f3dd99af00ea5a/brotli-1.2.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7547369c4392b47d30a3467fe8c3330b4f2e0f7730e45e3103d7d636678a808b", size = 1484594 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d3/93/14cf0b1216f43df5609f5b272050b0abd219e0b54ea80b47cef9867b45e7/brotli-1.2.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:fc1530af5c3c275b8524f2e24841cbe2599d74462455e9bae5109e9ff42e9361", size = 1593455 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b3/73/3183c9e41ca755713bdf2cc1d0810df742c09484e2e1ddd693bee53877c1/brotli-1.2.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d2d085ded05278d1c7f65560aae97b3160aeb2ea2c0b3e26204856beccb60888", size = 1488164 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/64/6a/0c78d8f3a582859236482fd9fa86a65a60328a00983006bcf6d83b7b2253/brotli-1.2.0-cp314-cp314-win32.whl", hash = "sha256:832c115a020e463c2f67664560449a7bea26b0c1fdd690352addad6d0a08714d", size = 339280 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f5/10/56978295c14794b2c12007b07f3e41ba26acda9257457d7085b0bb3bb90c/brotli-1.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:e7c0af964e0b4e3412a0ebf341ea26ec767fa0b4cf81abb5e897c9338b5ad6a3", size = 375639 },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "certifi"
|
name = "certifi"
|
||||||
version = "2026.1.4"
|
version = "2026.1.4"
|
||||||
@@ -1094,6 +1134,19 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515 },
|
{ url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515 },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "h2"
|
||||||
|
version = "4.3.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "hpack" },
|
||||||
|
{ name = "hyperframe" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/1d/17/afa56379f94ad0fe8defd37d6eb3f89a25404ffc71d4d848893d270325fc/h2-4.3.0.tar.gz", hash = "sha256:6c59efe4323fa18b47a632221a1888bd7fde6249819beda254aeca909f221bf1", size = 2152026 }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/69/b2/119f6e6dcbd96f9069ce9a2665e0146588dc9f88f29549711853645e736a/h2-4.3.0-py3-none-any.whl", hash = "sha256:c438f029a25f7945c69e0ccf0fb951dc3f73a5f6412981daee861431b70e2bdd", size = 61779 },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hatch-vcs"
|
name = "hatch-vcs"
|
||||||
version = "0.5.0"
|
version = "0.5.0"
|
||||||
@@ -1122,6 +1175,15 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/0d/a5/48cb7efb8b4718b1a4c0c331e3364a3a33f614ff0d6afd2b93ee883d3c47/hatchling-1.28.0-py3-none-any.whl", hash = "sha256:dc48722b68b3f4bbfa3ff618ca07cdea6750e7d03481289ffa8be1521d18a961", size = 76075 },
|
{ url = "https://files.pythonhosted.org/packages/0d/a5/48cb7efb8b4718b1a4c0c331e3364a3a33f614ff0d6afd2b93ee883d3c47/hatchling-1.28.0-py3-none-any.whl", hash = "sha256:dc48722b68b3f4bbfa3ff618ca07cdea6750e7d03481289ffa8be1521d18a961", size = 76075 },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hpack"
|
||||||
|
version = "4.1.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/2c/48/71de9ed269fdae9c8057e5a4c0aa7402e8bb16f2c6e90b3aa53327b113f8/hpack-4.1.0.tar.gz", hash = "sha256:ec5eca154f7056aa06f196a557655c5b009b382873ac8d1e66e79e87535f1dca", size = 51276 }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/07/c6/80c95b1b2b94682a72cbdbfb85b81ae2daffa4291fbfa1b1464502ede10d/hpack-4.1.0-py3-none-any.whl", hash = "sha256:157ac792668d995c657d93111f46b4535ed114f0c9c8d672271bbec7eae1b496", size = 34357 },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "httpcore"
|
name = "httpcore"
|
||||||
version = "1.0.9"
|
version = "1.0.9"
|
||||||
@@ -1180,10 +1242,22 @@ wheels = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
[package.optional-dependencies]
|
[package.optional-dependencies]
|
||||||
|
http2 = [
|
||||||
|
{ name = "h2" },
|
||||||
|
]
|
||||||
socks = [
|
socks = [
|
||||||
{ name = "socksio" },
|
{ name = "socksio" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hyperframe"
|
||||||
|
version = "6.1.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/02/e7/94f8232d4a74cc99514c13a9f995811485a6903d48e5d952771ef6322e30/hyperframe-6.1.0.tar.gz", hash = "sha256:f630908a00854a7adeabd6382b43923a4c4cd4b821fcb527e6ab9e15382a3b08", size = 26566 }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/48/30/47d0bf6072f7252e6521f3447ccfa40b421b6824517f82854703d0f5a98b/hyperframe-6.1.0-py3-none-any.whl", hash = "sha256:b03380493a519fce58ea5af42e4a42317bf9bd425596f7a0835ffce80f1a42e5", size = 13007 },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "idna"
|
name = "idna"
|
||||||
version = "3.11"
|
version = "3.11"
|
||||||
|
|||||||
Reference in New Issue
Block a user