修项目成本按天接口在 Postgres 上的 Decimal 序列化崩溃

2026-10-26 项目成本总览实弹:总览一次通过,但 ?day= 请求让
服务端请求线程直接死掉、客户端只看到连接断开。日志根因:
Postgres 的 SUM(bigint) 返回 NUMERIC,psycopg2 映成 Decimal,
按天分支把 SUM 原值塞进 JSON 时 json.dumps 抛 TypeError。
这是按天路径的潜 bug(此前从未在生产 PG 上跑通过按天查询),
不是 PR #45 新加列引入的,但本次实弹把它暴露了出来。
修复:按天两分支与模型总览的数值全部 int() 转换;_send_json
加 Decimal 兜底(整数转 int、非整数转 float),防止同类泄漏
再把请求线程拖死。单测:按天端点 + _json_default。
This commit is contained in:
host committed 2026-10-26 23:17:25 +08:00
1 parent 70177989f2
commit e384245722
2 files changed
+82 -15

No files matched your search

+33 -15
View File
@@ -48,6 +48,7 @@ import time
import uuid import uuid
from collections import deque from collections import deque
from contextlib import contextmanager from contextlib import contextmanager
from decimal import Decimal
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import parse_qs, quote, urlparse from urllib.parse import parse_qs, quote, urlparse
@@ -135,6 +136,18 @@ TASK_STATUS_KEYS = (
) )
def _json_default(obj):
"""json 兜底:Postgres 的 SUM(bigint) 返回 NUMERIC,psycopg2
映成 Decimal,不处理它在 _send_json 里直接炸 TypeError、请求
线程死掉、客户端只看到连接断开(2026-10-26 项目成本按天实弹
就是这么断的)。整数值 Decimal 转 int,非整数转 float;其它
类型照旧抛 TypeError,不静默吞掉真正的类型错误。"""
if isinstance(obj, Decimal):
return int(obj) if obj == obj.to_integral_value() else float(obj)
raise TypeError("Object of type %s is not JSON serializable"
% obj.__class__.__name__)
def _rate_limit_from_env(name, default): def _rate_limit_from_env(name, default):
try: try:
value = int(os.environ.get(name) or default) value = int(os.environ.get(name) or default)
@@ -471,7 +484,7 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
pass pass
def _send_json(self, status, obj): def _send_json(self, status, obj):
body = json.dumps(obj).encode("utf-8") body = json.dumps(obj, default=_json_default).encode("utf-8")
self.send_response(status) self.send_response(status)
self.send_header("Content-Type", "application/json") self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body))) self.send_header("Content-Length", str(len(body)))
@@ -1101,11 +1114,13 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
" GROUP BY day", " GROUP BY day",
(project_id, day)).fetchone() (project_id, day)).fetchone()
if row: if row:
daily = [{"day": row["day"], "total_tokens": row["t"], daily = [{"day": row["day"],
"usd_micros": row["u"], "turns": row["c"], "total_tokens": int(row["t"]),
"fallback_count": row["f"], "usd_micros": int(row["u"]),
"cached_input_tokens": row["cached"], "turns": int(row["c"]),
"unpriced_actual_tokens": row["unpriced"]}] "fallback_count": int(row["f"]),
"cached_input_tokens": int(row["cached"]),
"unpriced_actual_tokens": int(row["unpriced"])}]
elif days is not None: elif days is not None:
try: try:
n = int(days) n = int(days)
@@ -1126,11 +1141,13 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
" FROM cost_records WHERE project_id = ? AND day >= ?" " FROM cost_records WHERE project_id = ? AND day >= ?"
" GROUP BY day ORDER BY day", " GROUP BY day ORDER BY day",
(project_id, cutoff)).fetchall() (project_id, cutoff)).fetchall()
daily = [{"day": r["day"], "total_tokens": r["t"], daily = [{"day": r["day"],
"usd_micros": r["u"], "turns": r["c"], "total_tokens": int(r["t"]),
"fallback_count": r["f"], "usd_micros": int(r["u"]),
"cached_input_tokens": r["cached"], "turns": int(r["c"]),
"unpriced_actual_tokens": r["unpriced"]} "fallback_count": int(r["f"]),
"cached_input_tokens": int(r["cached"]),
"unpriced_actual_tokens": int(r["unpriced"])}
for r in rows] for r in rows]
finally: finally:
try: try:
@@ -1206,13 +1223,14 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
self._send_json(200, { self._send_json(200, {
"priority_list": priority, "priority_list": priority,
"model_stats": [ "model_stats": [
{"model": r["model"], "turns": r["turns"], {"model": r["model"], "turns": int(r["turns"]),
"total_tokens": r["tokens"], "total_tokens": int(r["tokens"]),
"fallback_count": r["fallbacks"]} for r in rows], "fallback_count": int(r["fallbacks"])} for r in rows],
"fallback_events": [ "fallback_events": [
{"task_id": r["task_id"], "project_id": r["project_id"], {"task_id": r["task_id"], "project_id": r["project_id"],
"model": r["model"], "model": r["model"],
"fallback_count": r["fallback_count"]} for r in fb_rows], "fallback_count": int(r["fallback_count"])}
for r in fb_rows],
}) })
# -- 任务 ---------------------------------------------------------- # -- 任务 ----------------------------------------------------------
+49
View File
@@ -196,6 +196,55 @@ class EndpointsTestCase(unittest.TestCase):
self.assertEqual(status, 200) self.assertEqual(status, 200)
self.assertEqual(body["overall"]["turns"], 2) self.assertEqual(body["overall"]["turns"], 2)
def test_project_cost_daily(self):
from datetime import datetime, timezone
status, task = self._req(
"POST", "/tasks",
{"project_id": self._pid(), "prompt": "做加法"}, self.owner_key)
self.assertEqual(status, 201)
store = CostStore(self.db_path)
try:
store.record(task["id"], self._pid(), "gpt-6.1-sol",
input_tokens=100, output_tokens=50,
cached_input_tokens=40, usd_micros=1234,
source="actual", fallback_count=1)
finally:
store._conn.close()
today = datetime.now(timezone.utc).strftime("%Y-%m-%d")
status, body = self._req(
"GET", "/projects/%s/cost?day=%s" % (self._pid(), today),
key=self.owner_key)
self.assertEqual(status, 200)
self.assertEqual(len(body["daily"]), 1)
row = body["daily"][0]
self.assertEqual(row["day"], today)
self.assertEqual(row["total_tokens"], 150)
self.assertEqual(row["usd_micros"], 1234)
self.assertEqual(row["turns"], 1)
self.assertEqual(row["fallback_count"], 1)
self.assertEqual(row["cached_input_tokens"], 40)
self.assertEqual(row["unpriced_actual_tokens"], 0)
status, body = self._req(
"GET", "/projects/%s/cost?days=7" % self._pid(),
key=self.owner_key)
self.assertEqual(status, 200)
self.assertEqual(
[r for r in body["daily"] if r["day"] == today][0]["turns"], 1)
status, body = self._req(
"GET", "/projects/%s/cost?day=2000-01-01" % self._pid(),
key=self.owner_key)
self.assertEqual(status, 200)
self.assertEqual(body["daily"], [])
def test_json_default_decimal(self):
from decimal import Decimal
from cloud.server import _json_default
self.assertEqual(_json_default(Decimal("10424")), 10424)
self.assertIsInstance(_json_default(Decimal("10424")), int)
self.assertEqual(_json_default(Decimal("1.5")), 1.5)
with self.assertRaises(TypeError):
_json_default(object())
# -- 模型总览 ------------------------------------------------------- # -- 模型总览 -------------------------------------------------------
def test_models_overview_isolated(self): def test_models_overview_isolated(self):
status, task = self._req( status, task = self._req(