fix(chat): lower unavailable media logs
This commit is contained in:
@@ -1,9 +1,14 @@
|
|||||||
import { describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
import { Result } from "@/utils";
|
import { Result } from "@/utils";
|
||||||
|
|
||||||
import { ChatMediaCacheCoordinator } from "../chat_media_cache_coordinator";
|
import { ChatMediaCacheCoordinator } from "../chat_media_cache_coordinator";
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
describe("ChatMediaCacheCoordinator identity isolation", () => {
|
describe("ChatMediaCacheCoordinator identity isolation", () => {
|
||||||
it("looks up media only in the current identity namespace", async () => {
|
it("looks up media only in the current identity namespace", async () => {
|
||||||
const getMedia = vi.fn(async () => Result.ok(null));
|
const getMedia = vi.fn(async () => Result.ok(null));
|
||||||
@@ -27,6 +32,9 @@ describe("ChatMediaCacheCoordinator identity isolation", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("does not fall back to an anonymous cache without an identity", async () => {
|
it("does not fall back to an anonymous cache without an identity", async () => {
|
||||||
|
const error = vi
|
||||||
|
.spyOn(console, "error")
|
||||||
|
.mockImplementation(() => undefined);
|
||||||
const getMedia = vi.fn(async () => Result.ok(null));
|
const getMedia = vi.fn(async () => Result.ok(null));
|
||||||
const coordinator = new ChatMediaCacheCoordinator(
|
const coordinator = new ChatMediaCacheCoordinator(
|
||||||
{
|
{
|
||||||
@@ -43,5 +51,42 @@ describe("ChatMediaCacheCoordinator identity isolation", () => {
|
|||||||
|
|
||||||
expect(Result.isErr(result)).toBe(true);
|
expect(Result.isErr(result)).toBe(true);
|
||||||
expect(getMedia).not.toHaveBeenCalled();
|
expect(getMedia).not.toHaveBeenCalled();
|
||||||
|
expect(error).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining("ERROR [Result] Result.wrap caught exception"),
|
||||||
|
expect.any(Object),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("logs expired remote media responses at info level", async () => {
|
||||||
|
vi.stubGlobal(
|
||||||
|
"fetch",
|
||||||
|
vi.fn(async () =>
|
||||||
|
new Response(null, { status: 400, statusText: "Bad Request" }),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const info = vi.spyOn(console, "info").mockImplementation(() => undefined);
|
||||||
|
const error = vi
|
||||||
|
.spyOn(console, "error")
|
||||||
|
.mockImplementation(() => undefined);
|
||||||
|
const coordinator = new ChatMediaCacheCoordinator({
|
||||||
|
getMedia: vi.fn(async () => Result.ok(null)),
|
||||||
|
saveMedia: vi.fn(async () => Result.ok(undefined)),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await coordinator.cacheRemoteMedia(
|
||||||
|
{
|
||||||
|
messageId: "message-expired",
|
||||||
|
kind: "image",
|
||||||
|
remoteUrl: "https://media.example/expired.jpg",
|
||||||
|
},
|
||||||
|
"user:account-a",
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(Result.isErr(result)).toBe(true);
|
||||||
|
expect(info).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining("INFO [Result] Result.wrap caught exception"),
|
||||||
|
expect.any(Object),
|
||||||
|
);
|
||||||
|
expect(error).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -27,6 +27,23 @@ import {
|
|||||||
|
|
||||||
const log = new Logger("DataRepositoriesChatMediaCacheCoordinator");
|
const log = new Logger("DataRepositoriesChatMediaCacheCoordinator");
|
||||||
|
|
||||||
|
class ChatMediaDownloadError extends Error {
|
||||||
|
override readonly cause?: unknown;
|
||||||
|
readonly status?: number;
|
||||||
|
|
||||||
|
constructor(message: string, options?: { cause?: unknown; status?: number }) {
|
||||||
|
super(message);
|
||||||
|
this.name = "ChatMediaDownloadError";
|
||||||
|
this.cause = options?.cause;
|
||||||
|
this.status = options?.status;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isChatMediaDownloadError(error: Error): boolean {
|
||||||
|
if (error instanceof ChatMediaDownloadError) return true;
|
||||||
|
return error.cause instanceof ChatMediaDownloadError;
|
||||||
|
}
|
||||||
|
|
||||||
type ChatMediaStorage = Pick<
|
type ChatMediaStorage = Pick<
|
||||||
LocalChatMediaStorage,
|
LocalChatMediaStorage,
|
||||||
"getMedia" | "saveMedia"
|
"getMedia" | "saveMedia"
|
||||||
@@ -55,10 +72,16 @@ export class ChatMediaCacheCoordinator {
|
|||||||
input: CacheRemoteChatMediaInput,
|
input: CacheRemoteChatMediaInput,
|
||||||
identity?: string,
|
identity?: string,
|
||||||
): Promise<Result<LocalChatMediaRow>> {
|
): Promise<Result<LocalChatMediaRow>> {
|
||||||
return Result.wrap(async () => {
|
return Result.wrap(
|
||||||
|
async () => {
|
||||||
const ownerKey = await this._requireIdentity(identity);
|
const ownerKey = await this._requireIdentity(identity);
|
||||||
return this._cacheRemoteMediaOrThrow(input, ownerKey);
|
return this._cacheRemoteMediaOrThrow(input, ownerKey);
|
||||||
});
|
},
|
||||||
|
{
|
||||||
|
errorLogLevel: (error) =>
|
||||||
|
isChatMediaDownloadError(error) ? "info" : "error",
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async prefetchMediaForMessages(
|
async prefetchMediaForMessages(
|
||||||
@@ -93,12 +116,19 @@ export class ChatMediaCacheCoordinator {
|
|||||||
try {
|
try {
|
||||||
await this._cacheRemoteMediaOrThrow(target, ownerKey);
|
await this._cacheRemoteMediaOrThrow(target, ownerKey);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
log.warn("[chat-media] prefetch failed", {
|
const details = {
|
||||||
messageId: target.messageId,
|
messageId: target.messageId,
|
||||||
kind: target.kind,
|
kind: target.kind,
|
||||||
remoteUrl: target.remoteUrl,
|
|
||||||
error,
|
error,
|
||||||
|
};
|
||||||
|
if (error instanceof ChatMediaDownloadError) {
|
||||||
|
log.info("[chat-media] remote media unavailable during prefetch", {
|
||||||
|
...details,
|
||||||
|
status: error.status,
|
||||||
});
|
});
|
||||||
|
} else {
|
||||||
|
log.warn("[chat-media] prefetch failed", details);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -117,19 +147,34 @@ export class ChatMediaCacheCoordinator {
|
|||||||
if (Result.isErr(existingResult)) throw existingResult.error;
|
if (Result.isErr(existingResult)) throw existingResult.error;
|
||||||
if (existingResult.data) return existingResult.data;
|
if (existingResult.data) return existingResult.data;
|
||||||
|
|
||||||
const response = await fetch(input.remoteUrl, {
|
let response: Response;
|
||||||
|
try {
|
||||||
|
response = await fetch(input.remoteUrl, {
|
||||||
method: "GET",
|
method: "GET",
|
||||||
cache: "force-cache",
|
cache: "force-cache",
|
||||||
});
|
});
|
||||||
|
} catch (error) {
|
||||||
|
throw new ChatMediaDownloadError("Media download request failed.", {
|
||||||
|
cause: error,
|
||||||
|
});
|
||||||
|
}
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(
|
throw new ChatMediaDownloadError(
|
||||||
`Media download failed: ${response.status} ${response.statusText}`,
|
`Media download failed: ${response.status} ${response.statusText}`,
|
||||||
|
{ status: response.status },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const bytes = await response.arrayBuffer();
|
let bytes: ArrayBuffer;
|
||||||
|
try {
|
||||||
|
bytes = await response.arrayBuffer();
|
||||||
|
} catch (error) {
|
||||||
|
throw new ChatMediaDownloadError("Media download body could not be read.", {
|
||||||
|
cause: error,
|
||||||
|
});
|
||||||
|
}
|
||||||
if (bytes.byteLength <= 0) {
|
if (bytes.byteLength <= 0) {
|
||||||
throw new Error("Media download returned empty bytes.");
|
throw new ChatMediaDownloadError("Media download returned empty bytes.");
|
||||||
}
|
}
|
||||||
const mimeType =
|
const mimeType =
|
||||||
response.headers.get("content-type") ||
|
response.headers.get("content-type") ||
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
import { AppException, ExceptionCode } from "@/core/errors";
|
import { AppException, ExceptionCode } from "@/core/errors";
|
||||||
|
|
||||||
import { Result, toError } from "../result";
|
import { Result, toError } from "../result";
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
describe("Result error normalization", () => {
|
describe("Result error normalization", () => {
|
||||||
it("normalizes unknown thrown values to AppException", async () => {
|
it("normalizes unknown thrown values to AppException", async () => {
|
||||||
const result = await Result.wrap(async () => {
|
const result = await Result.wrap(async () => {
|
||||||
@@ -25,4 +29,25 @@ describe("Result error normalization", () => {
|
|||||||
expect(error.message).toBe("Network connection failed.");
|
expect(error.message).toBe("Network connection failed.");
|
||||||
expect((error as AppException).code).toBe(ExceptionCode.network);
|
expect((error as AppException).code).toBe(ExceptionCode.network);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("allows expected failures to be logged at info level", async () => {
|
||||||
|
const info = vi.spyOn(console, "info").mockImplementation(() => undefined);
|
||||||
|
const error = vi
|
||||||
|
.spyOn(console, "error")
|
||||||
|
.mockImplementation(() => undefined);
|
||||||
|
|
||||||
|
const result = await Result.wrap(
|
||||||
|
async () => {
|
||||||
|
throw new Error("expected failure");
|
||||||
|
},
|
||||||
|
{ errorLogLevel: "info" },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(Result.isErr(result)).toBe(true);
|
||||||
|
expect(info).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining("INFO [Result] Result.wrap caught exception"),
|
||||||
|
expect.any(Object),
|
||||||
|
);
|
||||||
|
expect(error).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+22
-5
@@ -17,6 +17,14 @@ export type Result<T> =
|
|||||||
/** Result.wrap 专用 logger —— 自动带 component: "Result" 标签 */
|
/** Result.wrap 专用 logger —— 自动带 component: "Result" 标签 */
|
||||||
const log = new Logger("Result");
|
const log = new Logger("Result");
|
||||||
|
|
||||||
|
type ResultErrorLogLevel = "info" | "error";
|
||||||
|
|
||||||
|
export interface ResultWrapOptions {
|
||||||
|
errorLogLevel?:
|
||||||
|
| ResultErrorLogLevel
|
||||||
|
| ((error: Error) => ResultErrorLogLevel);
|
||||||
|
}
|
||||||
|
|
||||||
export const Result = {
|
export const Result = {
|
||||||
/** 构造成功结果。 */
|
/** 构造成功结果。 */
|
||||||
ok<T>(data: T): Result<T> {
|
ok<T>(data: T): Result<T> {
|
||||||
@@ -56,15 +64,24 @@ export const Result = {
|
|||||||
* 3. 不重新抛出 —— 调用方拿到的永远是 `Result<T>`(throw 风格调用方可在
|
* 3. 不重新抛出 —— 调用方拿到的永远是 `Result<T>`(throw 风格调用方可在
|
||||||
* 最外层显式 `throw r.error`)
|
* 最外层显式 `throw r.error`)
|
||||||
*/
|
*/
|
||||||
async wrap<T>(thunk: () => Promise<T>): Promise<Result<T>> {
|
async wrap<T>(
|
||||||
|
thunk: () => Promise<T>,
|
||||||
|
options?: ResultWrapOptions,
|
||||||
|
): Promise<Result<T>> {
|
||||||
try {
|
try {
|
||||||
return Result.ok(await thunk());
|
return Result.ok(await thunk());
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const err = toError(e);
|
const err = toError(e);
|
||||||
log.error(
|
const logLevel =
|
||||||
{ err, stack: err.stack, type: err.constructor.name },
|
typeof options?.errorLogLevel === "function"
|
||||||
"Result.wrap caught exception",
|
? options.errorLogLevel(err)
|
||||||
);
|
: options?.errorLogLevel ?? "error";
|
||||||
|
const details = { err, stack: err.stack, type: err.constructor.name };
|
||||||
|
if (logLevel === "info") {
|
||||||
|
log.info(details, "Result.wrap caught exception");
|
||||||
|
} else {
|
||||||
|
log.error(details, "Result.wrap caught exception");
|
||||||
|
}
|
||||||
return Result.err(err);
|
return Result.err(err);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user