修项目成本按天接口在 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:
1 parent
70177989f2
commit
e384245722
2 files changed
+82
-15
No files matched your search
+33
-15
@@ -48,6 +48,7 @@ import time
|
||||
import uuid
|
||||
from collections import deque
|
||||
from contextlib import contextmanager
|
||||
from decimal import Decimal
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
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):
|
||||
try:
|
||||
value = int(os.environ.get(name) or default)
|
||||
@@ -471,7 +484,7 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
|
||||
pass
|
||||
|
||||
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_header("Content-Type", "application/json")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
@@ -1101,11 +1114,13 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
|
||||
" GROUP BY day",
|
||||
(project_id, day)).fetchone()
|
||||
if row:
|
||||
daily = [{"day": row["day"], "total_tokens": row["t"],
|
||||
"usd_micros": row["u"], "turns": row["c"],
|
||||
"fallback_count": row["f"],
|
||||
"cached_input_tokens": row["cached"],
|
||||
"unpriced_actual_tokens": row["unpriced"]}]
|
||||
daily = [{"day": row["day"],
|
||||
"total_tokens": int(row["t"]),
|
||||
"usd_micros": int(row["u"]),
|
||||
"turns": int(row["c"]),
|
||||
"fallback_count": int(row["f"]),
|
||||
"cached_input_tokens": int(row["cached"]),
|
||||
"unpriced_actual_tokens": int(row["unpriced"])}]
|
||||
elif days is not None:
|
||||
try:
|
||||
n = int(days)
|
||||
@@ -1126,11 +1141,13 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
|
||||
" FROM cost_records WHERE project_id = ? AND day >= ?"
|
||||
" GROUP BY day ORDER BY day",
|
||||
(project_id, cutoff)).fetchall()
|
||||
daily = [{"day": r["day"], "total_tokens": r["t"],
|
||||
"usd_micros": r["u"], "turns": r["c"],
|
||||
"fallback_count": r["f"],
|
||||
"cached_input_tokens": r["cached"],
|
||||
"unpriced_actual_tokens": r["unpriced"]}
|
||||
daily = [{"day": r["day"],
|
||||
"total_tokens": int(r["t"]),
|
||||
"usd_micros": int(r["u"]),
|
||||
"turns": int(r["c"]),
|
||||
"fallback_count": int(r["f"]),
|
||||
"cached_input_tokens": int(r["cached"]),
|
||||
"unpriced_actual_tokens": int(r["unpriced"])}
|
||||
for r in rows]
|
||||
finally:
|
||||
try:
|
||||
@@ -1206,13 +1223,14 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
|
||||
self._send_json(200, {
|
||||
"priority_list": priority,
|
||||
"model_stats": [
|
||||
{"model": r["model"], "turns": r["turns"],
|
||||
"total_tokens": r["tokens"],
|
||||
"fallback_count": r["fallbacks"]} for r in rows],
|
||||
{"model": r["model"], "turns": int(r["turns"]),
|
||||
"total_tokens": int(r["tokens"]),
|
||||
"fallback_count": int(r["fallbacks"])} for r in rows],
|
||||
"fallback_events": [
|
||||
{"task_id": r["task_id"], "project_id": r["project_id"],
|
||||
"model": r["model"],
|
||||
"fallback_count": r["fallback_count"]} for r in fb_rows],
|
||||
"fallback_count": int(r["fallback_count"])}
|
||||
for r in fb_rows],
|
||||
})
|
||||
|
||||
# -- 任务 ----------------------------------------------------------
|
||||
|
||||
@@ -196,6 +196,55 @@ class EndpointsTestCase(unittest.TestCase):
|
||||
self.assertEqual(status, 200)
|
||||
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):
|
||||
status, task = self._req(
|
||||
|
||||
Reference in new issue
Block a user