fix: restore authenticated server-rendered data
Security Quality Gate / npm audit, Bandit, pip-audit, detect-secrets (pull_request) Successful in 10m53s
Security Quality Gate / npm audit, Bandit, pip-audit, detect-secrets (pull_request) Successful in 10m53s
This commit is contained in:
@@ -4,8 +4,8 @@
|
|||||||
|
|
||||||
## 结论
|
## 结论
|
||||||
|
|
||||||
- **内部 UAT / 受控单租户试点:有条件可进入。** 当前版本需先部署到 `192.168.3.90` smoke,并通过本文列出的安全、身份和真实 AI 抽样门槛。
|
- **内部 UAT / 受控单租户试点:可以进入。** 当前候选版本已部署到 `192.168.3.90` smoke,并通过远端身份边界、核心页面、资产计数、schema-check 和 Docker smoke 验证。
|
||||||
- **公开多企业 SaaS 生产:当前 No-Go。** 阻断项不是模板或 Prompt 数量,而是真实邮件交付、真实 AI Provider 评估、内容法务签批和本轮身份安全改动尚未完成远端验证。
|
- **公开多企业 SaaS 生产:当前 No-Go。** 阻断项不是模板或 Prompt 数量,而是真实邮件交付、真实 AI Provider 评估、内容法务签批和生产运行保障。
|
||||||
- 不建议继续横向增加模板数量。下一阶段应优先完成内容治理、真实模型质量评估和生产运行保障。
|
- 不建议继续横向增加模板数量。下一阶段应优先完成内容治理、真实模型质量评估和生产运行保障。
|
||||||
|
|
||||||
## 当前能力
|
## 当前能力
|
||||||
@@ -22,7 +22,7 @@
|
|||||||
|
|
||||||
资产精确核对证据:
|
资产精确核对证据:
|
||||||
|
|
||||||
- `project-docs/qa-artifacts/v2-asset-count/v2-asset-count-2026-07-13T02-53-18.128434+00-00.json`
|
- `project-docs/qa-artifacts/v2-asset-count/v2-asset-count-2026-07-13T05-33-30.778054+00-00.json`
|
||||||
- 模板:`30 / 30 / 30`
|
- 模板:`30 / 30 / 30`
|
||||||
- 标准条款:`8 / 8 / 8`
|
- 标准条款:`8 / 8 / 8`
|
||||||
- 审查规则:`8 / 8 / 8`
|
- 审查规则:`8 / 8 / 8`
|
||||||
@@ -30,7 +30,7 @@
|
|||||||
|
|
||||||
## 本轮安全硬化
|
## 本轮安全硬化
|
||||||
|
|
||||||
本轮本地代码已完成:
|
本轮代码和远端 smoke 已完成:
|
||||||
|
|
||||||
1. Web 会话从可篡改的 Base64 JSON 改为带有效期的 HMAC 签名 cookie,并启用 `HttpOnly`、`SameSite=Lax` 和生产 `Secure`。
|
1. Web 会话从可篡改的 Base64 JSON 改为带有效期的 HMAC 签名 cookie,并启用 `HttpOnly`、`SameSite=Lax` 和生产 `Secure`。
|
||||||
2. Contract API 不再在生产环境无条件信任 `X-Tenant-Id` / `X-User-Id`,由受控 Web 代理密钥建立信任边界。
|
2. Contract API 不再在生产环境无条件信任 `X-Tenant-Id` / `X-User-Id`,由受控 Web 代理密钥建立信任边界。
|
||||||
@@ -39,16 +39,29 @@
|
|||||||
5. Contract API、审查 worker 和 Python SDK 支持向 AI Platform 传递服务密钥。
|
5. Contract API、审查 worker 和 Python SDK 支持向 AI Platform 传递服务密钥。
|
||||||
6. 生产响应不再返回邀请原始 token 和邮箱开发验证码。
|
6. 生产响应不再返回邀请原始 token 和邮箱开发验证码。
|
||||||
|
|
||||||
这些变更在本地测试和生产构建通过,但在部署到 smoke 前仍属于“本地已修、远端待验”。
|
这些变更已通过本地测试、生产构建和远端 smoke 验证。
|
||||||
|
|
||||||
|
## 远端验证证据
|
||||||
|
|
||||||
|
- Docker smoke:`project-docs/qa-artifacts/v2-smoke-docker-deploy/2026-07-13T13-18-14/v2-smoke-docker-deploy-summary.md`,8 个长期服务健康,迁移、Prompt 导入、黄金样本和审查 worker smoke 通过。
|
||||||
|
- 鉴权边界:Contract API 与 AI Platform 数据接口缺失或使用错误服务密钥时返回 `401`,使用当前 smoke 服务密钥时返回 `200`;健康端点保持可用。
|
||||||
|
- Schema:Contract API `status=ok`,Alembic `20260710_0030`;AI Platform Alembic `20260703_0021`,仅 `provider_runtime=warning`,原因是 smoke 明确关闭真实 Provider 并启用 fallback。
|
||||||
|
- Web 登录:`admin@example.com` 可进入默认租户;合同台账显示 `12,129` 条,模板库和模板起草入口均显示 30 个模板,文件仓库、AI Trace、用户租户页均正常加载。
|
||||||
|
- SSR 认证回归:修复了认证硬化后 Server Component 裸请求 Contract API 导致合同和模板页面错误显示为 0 的问题;服务端缓存键已纳入租户、用户和角色,浏览器端继续只访问同源代理。
|
||||||
|
- Word 交付:抽样合同下载返回 `HT-20260713-70C052.docx`,MIME 为 Office Open XML,解码后 37,502 字节且 DOCX 文件头有效。
|
||||||
|
- 资产精确核对:模板 `30 / 30 / 30`、条款 `8 / 8 / 8`、规则 `8 / 8 / 8`、Prompt `46 / 46 / 46`,法律条文运行时 `789,867` 条。
|
||||||
|
- 法律问答工程链路可用,但 smoke fallback 抽样的引用相关性不足,不能作为生产 AI 质量证据;必须在真实 Provider、fallback 关闭后重新验收。
|
||||||
|
- PR 旧提交 `5f89c112` 的 CI 失败来自 Linux detect-secrets 对 SDK 测试固定假值的单个平台差异误报;已使用标准 inline allowlist 标记,安全规则本身未放宽,待当前提交触发 CI 重跑。
|
||||||
|
|
||||||
## 生产阻断项
|
## 生产阻断项
|
||||||
|
|
||||||
### P0-1 远端发布候选验证
|
### P0-1 远端发布候选验证(已完成)
|
||||||
|
|
||||||
- 部署当前代码到 `192.168.3.90` smoke。
|
- [x] 部署当前代码到 `192.168.3.90` smoke。
|
||||||
- 验证无代理密钥直连 Contract API / AI Platform 数据接口返回 `401`。
|
- [x] 验证无代理密钥直连 Contract API / AI Platform 数据接口返回 `401`。
|
||||||
- 验证 Web 登录后合同、模板、文件、AI Trace、知识库均正常。
|
- [x] 验证 Web 登录后合同、模板、文件、AI Trace、知识库均正常。
|
||||||
- 复跑两套 schema-check、资产精确核对、Docker smoke、核心接口压测和安全扫描。
|
- [x] 复跑两套 schema-check、资产精确核对和 Docker smoke。
|
||||||
|
- [ ] 当前提交 CI 全绿后,复跑带最新候选代码的接口压测与安全质量门,作为生产审批证据。
|
||||||
|
|
||||||
### P0-2 真实 AI Provider 与质量验收
|
### P0-2 真实 AI Provider 与质量验收
|
||||||
|
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ def test_ai_platform_client_posts_legal_qa_with_context_headers() -> None:
|
|||||||
client = AiPlatformClient(
|
client = AiPlatformClient(
|
||||||
"http://ai-platform.test",
|
"http://ai-platform.test",
|
||||||
transport=httpx.MockTransport(handler),
|
transport=httpx.MockTransport(handler),
|
||||||
service_key="ai-platform-service-key",
|
service_key="ai-platform-service-key", # pragma: allowlist secret
|
||||||
)
|
)
|
||||||
|
|
||||||
response = client.run_legal_qa(
|
response = client.run_legal_qa(
|
||||||
|
|||||||
@@ -3,15 +3,17 @@
|
|||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { Building2, MailCheck, Plus, Save, Settings, ShieldCheck, UsersRound } from "lucide-react";
|
import { Building2, MailCheck, Plus, Save, Settings, ShieldCheck, UsersRound } from "lucide-react";
|
||||||
import {
|
import {
|
||||||
getAccountSettings,
|
|
||||||
getIdentityRoles,
|
|
||||||
getIdentityTenants,
|
|
||||||
getIdentityUsers,
|
|
||||||
type AccountSettings,
|
type AccountSettings,
|
||||||
type RolePermissionsPage,
|
type RolePermissionsPage,
|
||||||
type TenantInfo,
|
type TenantInfo,
|
||||||
type TenantUserItem,
|
type TenantUserItem,
|
||||||
} from "@/services/contracts";
|
} from "@/services/contracts";
|
||||||
|
import {
|
||||||
|
getAccountSettings,
|
||||||
|
getIdentityRoles,
|
||||||
|
getIdentityTenants,
|
||||||
|
getIdentityUsers,
|
||||||
|
} from "@/services/contracts-client";
|
||||||
import { getRoleLabel, type UserRole } from "@/lib/access-control";
|
import { getRoleLabel, type UserRole } from "@/lib/access-control";
|
||||||
|
|
||||||
type TenantStatus = TenantInfo["status"];
|
type TenantStatus = TenantInfo["status"];
|
||||||
|
|||||||
@@ -3,7 +3,8 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { BookOpenCheck, ClipboardList, Loader2, MessageSquareText, RotateCcw, Send, ShieldCheck } from "lucide-react";
|
import { BookOpenCheck, ClipboardList, Loader2, MessageSquareText, RotateCcw, Send, ShieldCheck } from "lucide-react";
|
||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { runLegalQa, type LegalQaCitation, type LegalQaResponse } from "@/services/contracts";
|
import type { LegalQaCitation, LegalQaResponse } from "@/services/contracts";
|
||||||
|
import { runLegalQa } from "@/services/contracts-client";
|
||||||
|
|
||||||
type LegalQaClientProps = {
|
type LegalQaClientProps = {
|
||||||
initialQuestion: string;
|
initialQuestion: string;
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import type {
|
||||||
|
AccountSettings,
|
||||||
|
LegalQaRequest,
|
||||||
|
LegalQaResponse,
|
||||||
|
RolePermissionsPage,
|
||||||
|
TenantPage,
|
||||||
|
TenantUserPage,
|
||||||
|
} from "@/services/contracts";
|
||||||
|
|
||||||
|
export async function runLegalQa(request: LegalQaRequest): Promise<LegalQaResponse> {
|
||||||
|
const response = await fetch("/api/contract-platform/legal/qa", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
question: request.question,
|
||||||
|
matter_context: request.matter_context || undefined,
|
||||||
|
knowledge_base_ids: request.knowledge_base_ids ?? [],
|
||||||
|
retrieval_strategy: request.retrieval_strategy ?? "hybrid",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
const payload = await response.json().catch(() => null) as { detail?: string; message?: string } | null;
|
||||||
|
throw new Error(payload?.detail || payload?.message || "Legal QA request failed.");
|
||||||
|
}
|
||||||
|
return await response.json() as LegalQaResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getIdentityTenants(): Promise<TenantPage> {
|
||||||
|
const payload = await fetchClientJson<TenantPage>("/api/contract-platform/identity/tenants");
|
||||||
|
return payload ?? { items: [], total: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getIdentityUsers(tenantId?: string): Promise<TenantUserPage> {
|
||||||
|
const suffix = tenantId?.trim() ? `?tenant_id=${encodeURIComponent(tenantId.trim())}` : "";
|
||||||
|
const payload = await fetchClientJson<TenantUserPage>(`/api/contract-platform/identity/users${suffix}`);
|
||||||
|
return payload ?? { items: [], total: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getIdentityRoles(): Promise<RolePermissionsPage> {
|
||||||
|
const payload = await fetchClientJson<RolePermissionsPage>("/api/contract-platform/identity/roles");
|
||||||
|
return payload ?? { roles: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAccountSettings(): Promise<AccountSettings | null> {
|
||||||
|
return fetchClientJson<AccountSettings>("/api/contract-platform/identity/account");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchClientJson<T>(pathname: string): Promise<T | null> {
|
||||||
|
try {
|
||||||
|
const response = await fetch(pathname, { cache: "no-store" });
|
||||||
|
if (!response.ok) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return await response.json() as T;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
import { contracts as fallbackContracts, tasks as fallbackTasks } from "@/lib/mock-data";
|
import { contracts as fallbackContracts, tasks as fallbackTasks } from "@/lib/mock-data";
|
||||||
import { resolveCurrentRole, type UserRole } from "@/lib/access-control";
|
import { resolveCurrentRole, type UserRole } from "@/lib/access-control";
|
||||||
|
import { fetchContractApi } from "@/lib/contract-api";
|
||||||
|
import { getContractWebSession } from "@/lib/contract-session";
|
||||||
|
|
||||||
export type ContractListItem = {
|
export type ContractListItem = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -1276,8 +1278,15 @@ async function fetchJson<T>(
|
|||||||
const timeout = controller
|
const timeout = controller
|
||||||
? setTimeout(() => controller.abort(), options.timeoutMs)
|
? setTimeout(() => controller.abort(), options.timeoutMs)
|
||||||
: null;
|
: null;
|
||||||
|
const isServerRequest = typeof window === "undefined";
|
||||||
|
const session = isServerRequest ? await getContractWebSession() : null;
|
||||||
|
if (isServerRequest && !session) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
const url = resolveUrl(apiPathname, proxyPathname);
|
const url = resolveUrl(apiPathname, proxyPathname);
|
||||||
const cacheKey = typeof window === "undefined" && options.cacheTtlMs ? url : null;
|
const cacheKey = isServerRequest && options.cacheTtlMs
|
||||||
|
? `${session?.tenant_id}:${session?.user_id}:${session?.role}:${url}`
|
||||||
|
: null;
|
||||||
if (cacheKey) {
|
if (cacheKey) {
|
||||||
const cached = serverFetchCache.get(cacheKey);
|
const cached = serverFetchCache.get(cacheKey);
|
||||||
if (cached && cached.expiresAt > Date.now()) {
|
if (cached && cached.expiresAt > Date.now()) {
|
||||||
@@ -1289,7 +1298,9 @@ async function fetchJson<T>(
|
|||||||
cache: "no-store",
|
cache: "no-store",
|
||||||
signal: controller?.signal,
|
signal: controller?.signal,
|
||||||
};
|
};
|
||||||
const response = await fetch(url, fetchInit);
|
const response = isServerRequest
|
||||||
|
? await fetchContractApi(new URL(url), fetchInit)
|
||||||
|
: await fetch(url, fetchInit);
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,11 +4,12 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
import json
|
import json
|
||||||
|
import os
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from urllib.error import URLError
|
from urllib.error import URLError
|
||||||
from urllib.request import urlopen
|
from urllib.request import Request, urlopen
|
||||||
|
|
||||||
|
|
||||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||||
@@ -22,6 +23,10 @@ def main() -> int:
|
|||||||
parser.add_argument("--v1-root", type=Path, default=DEFAULT_V1_ROOT)
|
parser.add_argument("--v1-root", type=Path, default=DEFAULT_V1_ROOT)
|
||||||
parser.add_argument("--contract-api-base", default=DEFAULT_CONTRACT_API_BASE)
|
parser.add_argument("--contract-api-base", default=DEFAULT_CONTRACT_API_BASE)
|
||||||
parser.add_argument("--ai-platform-api-base", default=DEFAULT_AI_PLATFORM_API_BASE)
|
parser.add_argument("--ai-platform-api-base", default=DEFAULT_AI_PLATFORM_API_BASE)
|
||||||
|
parser.add_argument("--tenant-id", default="default")
|
||||||
|
parser.add_argument("--user-id", default="user-current-admin")
|
||||||
|
parser.add_argument("--contract-proxy-key-env", default="CONTRACT_API_WEB_PROXY_SHARED_SECRET")
|
||||||
|
parser.add_argument("--ai-service-key-env", default="AI_PLATFORM_SERVICE_SHARED_SECRET")
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--artifact-dir",
|
"--artifact-dir",
|
||||||
type=Path,
|
type=Path,
|
||||||
@@ -35,14 +40,34 @@ def main() -> int:
|
|||||||
stamp = checked_at.replace(":", "-").replace("+00:00", "Z")
|
stamp = checked_at.replace(":", "-").replace("+00:00", "Z")
|
||||||
contract_api_base = args.contract_api_base.rstrip("/")
|
contract_api_base = args.contract_api_base.rstrip("/")
|
||||||
ai_platform_api_base = args.ai_platform_api_base.rstrip("/")
|
ai_platform_api_base = args.ai_platform_api_base.rstrip("/")
|
||||||
|
contract_headers = identity_headers(args.tenant_id, args.user_id)
|
||||||
|
ai_platform_headers = identity_headers(args.tenant_id, args.user_id)
|
||||||
|
contract_proxy_key = os.getenv(args.contract_proxy_key_env, "").strip()
|
||||||
|
ai_service_key = os.getenv(args.ai_service_key_env, "").strip()
|
||||||
|
if contract_proxy_key:
|
||||||
|
contract_headers["X-Contract-Proxy-Key"] = contract_proxy_key
|
||||||
|
if ai_service_key:
|
||||||
|
ai_platform_headers["X-AI-Platform-Service-Key"] = ai_service_key
|
||||||
|
|
||||||
counts, parity = build_asset_report(args.v1_root, contract_api_base, ai_platform_api_base)
|
counts, parity = build_asset_report(
|
||||||
|
args.v1_root,
|
||||||
|
contract_api_base,
|
||||||
|
ai_platform_api_base,
|
||||||
|
contract_headers=contract_headers,
|
||||||
|
ai_platform_headers=ai_platform_headers,
|
||||||
|
)
|
||||||
result = {
|
result = {
|
||||||
"status": derive_status(counts, parity),
|
"status": derive_status(counts, parity),
|
||||||
"checked_at": checked_at,
|
"checked_at": checked_at,
|
||||||
"v1_root": str(args.v1_root),
|
"v1_root": str(args.v1_root),
|
||||||
"contract_api_base": contract_api_base,
|
"contract_api_base": contract_api_base,
|
||||||
"ai_platform_api_base": ai_platform_api_base,
|
"ai_platform_api_base": ai_platform_api_base,
|
||||||
|
"request_context": {
|
||||||
|
"tenant_id": args.tenant_id,
|
||||||
|
"user_id": args.user_id,
|
||||||
|
"contract_proxy_auth_configured": bool(contract_proxy_key),
|
||||||
|
"ai_service_auth_configured": bool(ai_service_key),
|
||||||
|
},
|
||||||
"counts": counts,
|
"counts": counts,
|
||||||
"parity": parity,
|
"parity": parity,
|
||||||
"notes": [
|
"notes": [
|
||||||
@@ -69,6 +94,9 @@ def build_asset_report(
|
|||||||
v1_root: Path,
|
v1_root: Path,
|
||||||
contract_api_base: str,
|
contract_api_base: str,
|
||||||
ai_platform_api_base: str,
|
ai_platform_api_base: str,
|
||||||
|
*,
|
||||||
|
contract_headers: dict[str, str] | None = None,
|
||||||
|
ai_platform_headers: dict[str, str] | None = None,
|
||||||
) -> tuple[dict[str, Any], dict[str, Any]]:
|
) -> tuple[dict[str, Any], dict[str, Any]]:
|
||||||
v1_phase13 = v1_root / "project-docs" / "phase13-legal-knowledge-template-library"
|
v1_phase13 = v1_root / "project-docs" / "phase13-legal-knowledge-template-library"
|
||||||
v1_templates = v1_phase13 / "contract-templates"
|
v1_templates = v1_phase13 / "contract-templates"
|
||||||
@@ -105,12 +133,12 @@ def build_asset_report(
|
|||||||
v2_prompt_keys = prompt_keys(read_json_file(v2_prompt_assets_file))
|
v2_prompt_keys = prompt_keys(read_json_file(v2_prompt_assets_file))
|
||||||
|
|
||||||
runtime_payloads = {
|
runtime_payloads = {
|
||||||
"templates": api_json(contract_api_base, "/api/v1/templates?page_size=100"),
|
"templates": api_json(contract_api_base, "/api/v1/templates?page_size=100", contract_headers),
|
||||||
"clauses": api_json(contract_api_base, "/api/v1/standard-clauses?page_size=100"),
|
"clauses": api_json(contract_api_base, "/api/v1/standard-clauses?page_size=100", contract_headers),
|
||||||
"rules": api_json(contract_api_base, "/api/v1/review-rules?page_size=100"),
|
"rules": api_json(contract_api_base, "/api/v1/review-rules?page_size=100", contract_headers),
|
||||||
"legal_articles": api_json(contract_api_base, "/api/v1/legal-articles?page_size=1"),
|
"legal_articles": api_json(contract_api_base, "/api/v1/legal-articles?page_size=1", contract_headers),
|
||||||
"contracts": api_json(contract_api_base, "/api/v1/contracts?page_size=1"),
|
"contracts": api_json(contract_api_base, "/api/v1/contracts?page_size=1", contract_headers),
|
||||||
"prompts": api_json(ai_platform_api_base, "/api/v1/prompt-templates"),
|
"prompts": api_json(ai_platform_api_base, "/api/v1/prompt-templates", ai_platform_headers),
|
||||||
}
|
}
|
||||||
runtime_template_ids = item_ids(runtime_payloads["templates"])
|
runtime_template_ids = item_ids(runtime_payloads["templates"])
|
||||||
runtime_clause_ids = item_ids(runtime_payloads["clauses"])
|
runtime_clause_ids = item_ids(runtime_payloads["clauses"])
|
||||||
@@ -212,9 +240,19 @@ def read_json_file(path: Path) -> Any:
|
|||||||
return {"__error__": f"Unable to read {path}: {exc}"}
|
return {"__error__": f"Unable to read {path}: {exc}"}
|
||||||
|
|
||||||
|
|
||||||
def api_json(base_url: str, path: str) -> Any:
|
def identity_headers(tenant_id: str, user_id: str) -> dict[str, str]:
|
||||||
|
headers: dict[str, str] = {}
|
||||||
|
if tenant_id.strip():
|
||||||
|
headers["X-Tenant-Id"] = tenant_id.strip()
|
||||||
|
if user_id.strip():
|
||||||
|
headers["X-User-Id"] = user_id.strip()
|
||||||
|
return headers
|
||||||
|
|
||||||
|
|
||||||
|
def api_json(base_url: str, path: str, headers: dict[str, str] | None = None) -> Any:
|
||||||
try:
|
try:
|
||||||
with urlopen(f"{base_url}{path}", timeout=20) as response:
|
request = Request(f"{base_url}{path}", headers=headers or {})
|
||||||
|
with urlopen(request, timeout=20) as response:
|
||||||
return json.loads(response.read().decode("utf-8"))
|
return json.loads(response.read().decode("utf-8"))
|
||||||
except (OSError, URLError, TimeoutError, json.JSONDecodeError) as exc:
|
except (OSError, URLError, TimeoutError, json.JSONDecodeError) as exc:
|
||||||
return {"__error__": f"{base_url}{path}: {exc}"}
|
return {"__error__": f"{base_url}{path}: {exc}"}
|
||||||
|
|||||||
Reference in New Issue
Block a user