Commit f5821c6b authored by ThinhNC's avatar ThinhNC

Merge branch 'feat/resilience-quota-waiver-and-i18n' into 'develop'

feat(be): exempt failed connection & provider quota jobs from daily usage quota

See merge request !15
parents 5a6974a8 ffc94a49
require("dotenv").config();
const { PrismaClient } = require("@prisma/client");
const prisma = new PrismaClient();
async function main() {
const jobs = await prisma.crawlJob.findMany({
orderBy: { createdAt: "desc" },
take: 3,
select: {
id: true,
startUrl: true,
status: true,
errorMessage: true,
createdAt: true,
},
});
for (const job of jobs) {
console.log("-----------------------------------------");
console.log("JOB:", job.id, job.startUrl, job.status);
console.log("ERROR MESSAGE:", job.errorMessage);
const logs = await prisma.crawlJobLog.findMany({
where: { jobId: job.id },
orderBy: { createdAt: "desc" },
take: 5,
});
console.log("LOGS:", logs.map(l => ({ level: l.level, message: l.message })));
}
}
main()
.catch(console.error)
.finally(() => prisma.$disconnect());
require("dotenv").config();
const axios = require("axios");
async function testFirecrawl() {
const apiKey = process.env.FIRECRAWL_API_KEY;
const baseUrl = process.env.FIRECRAWL_BASE_URL || "https://api.firecrawl.dev";
console.log("Using API Key:", apiKey ? apiKey.slice(0, 8) + "..." : "NONE");
console.log("Using Base URL:", baseUrl);
try {
const res = await axios.post(
`${baseUrl}/v1/scrape`,
{ url: "https://example.com" },
{
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
},
}
);
console.log("SUCCESS:", res.status, res.data);
} catch (err) {
if (err.response) {
console.log("RESPONSE ERROR STATUS:", err.response.status);
console.log("RESPONSE ERROR DATA:", JSON.stringify(err.response.data));
} else {
console.log("NETWORK/OTHER ERROR:", err.message);
}
}
}
testFirecrawl();
...@@ -33,11 +33,13 @@ export function mapCrawlError(rawError: string | null | undefined): string { ...@@ -33,11 +33,13 @@ export function mapCrawlError(rawError: string | null | undefined): string {
return "Trang web đích được bảo vệ bởi paywall (nội dung trả phí). Hệ thống không thể truy cập — trang được đánh dấu PAYWALL_DETECTED."; return "Trang web đích được bảo vệ bởi paywall (nội dung trả phí). Hệ thống không thể truy cập — trang được đánh dấu PAYWALL_DETECTED.";
} }
// 5. Lỗi API Key / Xác thực // 5. Lỗi API Key / Xác thực / Hạn mức Provider
if ( if (
err.includes("unauthorized") || err.includes("unauthorized") ||
err.includes("api key") || err.includes("api key") ||
err.includes("apikey") || err.includes("apikey") ||
err.includes("credit") ||
err.includes("402") ||
(err.includes("forbidden") && err.includes("key")) (err.includes("forbidden") && err.includes("key"))
) { ) {
return "Lỗi xác thực hệ thống cào dữ liệu (API Key không hợp lệ, hết hạn hoặc vượt quá giới hạn gói dịch vụ)."; return "Lỗi xác thực hệ thống cào dữ liệu (API Key không hợp lệ, hết hạn hoặc vượt quá giới hạn gói dịch vụ).";
...@@ -106,3 +108,64 @@ export function getErrorMessage(error: unknown): string { ...@@ -106,3 +108,64 @@ export function getErrorMessage(error: unknown): string {
} }
return String(error); return String(error);
} }
/**
* Kiểm tra xem lỗi cào dữ liệu có phải do mất kết nối mạng, timeout hoặc không thể truy cập host hay không.
* Các tác vụ thất bại do lỗi kết nối sẽ được miễn trừ và không tính vào hạn mức sử dụng (Quota) của người dùng.
*/
export function isConnectionLossError(
rawError: string | null | undefined,
): boolean {
if (!rawError) {
return false;
}
const err = rawError.toLowerCase();
return (
// Timeout / quá thời gian chờ kết nối
err.includes("timeout") ||
err.includes("timed out") ||
err.includes("etimedout") ||
err.includes("esockettimedout") ||
// Lỗi đứt kết nối mạng, reset hoặc từ chối kết nối
err.includes("econnrefused") ||
err.includes("econnreset") ||
err.includes("econnaborted") ||
err.includes("network error") ||
err.includes("network_error") ||
err.includes("connection reset") ||
err.includes("connection refused") ||
err.includes("connection lost") ||
err.includes("connection closed") ||
err.includes("connection error") ||
err.includes("socket hang up") ||
err.includes("network is unreachable") ||
err.includes("host unreachable") ||
err.includes("offline") ||
// Lỗi DNS / không tìm thấy host
err.includes("dns") ||
err.includes("getaddrinfo") ||
err.includes("enotfound") ||
// Lỗi từ nhà cung cấp cào / proxy / gateway (402, 500, 502, 503, 504, credit, bad gateway)
err.includes("402") ||
err.includes("credit") ||
err.includes("500") ||
err.includes("502") ||
err.includes("503") ||
err.includes("504") ||
err.includes("bad gateway") ||
err.includes("gateway") ||
err.includes("service unavailable") ||
// Các thông báo tiếng Việt tương ứng
err.includes("mất kết nối") ||
err.includes("lỗi kết nối") ||
err.includes("không thể kết nối") ||
err.includes("kết nối đến trang web đích bị quá thời gian") ||
err.includes("không thể phân giải tên miền") ||
err.includes("đã xảy ra lỗi trong quá trình cào dữ liệu") ||
err.includes("lỗi không xác định") ||
err.includes("lỗi xác thực hệ thống cào dữ liệu")
);
}
...@@ -122,4 +122,32 @@ describe("CrawlJobRepository soft-delete and quota retention", () => { ...@@ -122,4 +122,32 @@ describe("CrawlJobRepository soft-delete and quota retention", () => {
}), }),
); );
}); });
it("countJobsSince() waives jobs that failed due to connection loss from quota count", async () => {
const sinceDate = new Date("2026-09-08T00:00:00Z");
(prisma.crawlJob.count as jest.Mock).mockResolvedValue(4);
(prisma.crawlJob.findMany as jest.Mock).mockResolvedValue([
{ id: "job-failed-timeout", errorMessage: "Kết nối đến trang web đích bị quá thời gian (Timeout). Trang web phản hồi quá chậm." },
{ id: "job-failed-connreset", errorMessage: "connect ECONNRESET 192.168.1.1" },
]);
const billableCount = await repository.countJobsSince("user-1", sinceDate);
// 4 total minus 2 connection loss failed jobs = 2 billable jobs
expect(billableCount).toBe(2);
});
it("sumPagesCrawledByUser() waives pages from jobs that failed due to connection loss", async () => {
(prisma.crawlJob.aggregate as jest.Mock).mockResolvedValue({
_sum: { totalPages: 100 },
});
(prisma.crawlJob.findMany as jest.Mock).mockResolvedValue([
{ totalPages: 20, errorMessage: "getaddrinfo ENOTFOUND invalid-domain.xyz" },
]);
const pages = await repository.sumPagesCrawledByUser("user-1");
// 100 total minus 20 waived pages from DNS connection failure = 80 pages
expect(pages).toBe(80);
});
}); });
...@@ -2,6 +2,7 @@ import { prisma } from "../../database/prisma.client"; ...@@ -2,6 +2,7 @@ import { prisma } from "../../database/prisma.client";
import { CrawlJobStatus, CrawlMode, LogLevel, Prisma } from "@prisma/client"; import { CrawlJobStatus, CrawlMode, LogLevel, Prisma } from "@prisma/client";
import { CrawlJobQueryDto } from "./crawl-job.dto"; import { CrawlJobQueryDto } from "./crawl-job.dto";
import { JOB_STATUS } from "../../common/constants/job-status.constant"; import { JOB_STATUS } from "../../common/constants/job-status.constant";
import { isConnectionLossError } from "../../common/helpers/error-mapping.helper";
export class CrawlJobRepository { export class CrawlJobRepository {
create(data: { create(data: {
...@@ -332,13 +333,36 @@ export class CrawlJobRepository { ...@@ -332,13 +333,36 @@ export class CrawlJobRepository {
]); ]);
} }
countJobsSince(userId: string, sinceDate: Date): Promise<number> { async countJobsSince(userId: string, sinceDate: Date): Promise<number> {
return prisma.crawlJob.count({ const totalCount = await prisma.crawlJob.count({
where: { where: {
userId, userId,
createdAt: { gte: sinceDate }, createdAt: { gte: sinceDate },
}, },
}); });
try {
// Find jobs that failed due to connection loss with 0 successful pages
const failedJobs = await prisma.crawlJob.findMany({
where: {
userId,
status: JOB_STATUS.FAILED,
createdAt: { gte: sinceDate },
successPages: 0,
},
select: {
errorMessage: true,
},
});
const waivedCount = failedJobs.filter(
(job) => !job.errorMessage || isConnectionLossError(job.errorMessage),
).length;
return Math.max(0, totalCount - waivedCount);
} catch {
return totalCount;
}
} }
countConcurrentJobs( countConcurrentJobs(
...@@ -361,7 +385,33 @@ export class CrawlJobRepository { ...@@ -361,7 +385,33 @@ export class CrawlJobRepository {
where: { userId }, where: { userId },
_sum: { totalPages: true }, _sum: { totalPages: true },
}); });
return aggregate._sum.totalPages ?? 0; const totalPages = aggregate._sum.totalPages ?? 0;
try {
// Deduct un-crawled totalPages for jobs that failed due to connection error with 0 success pages
const failedJobs = await prisma.crawlJob.findMany({
where: {
userId,
status: JOB_STATUS.FAILED,
successPages: 0,
},
select: {
totalPages: true,
errorMessage: true,
},
});
const waivedPages = failedJobs
.filter(
(job) =>
!job.errorMessage || isConnectionLossError(job.errorMessage),
)
.reduce((sum, job) => sum + (job.totalPages ?? 0), 0);
return Math.max(0, totalPages - waivedPages);
} catch {
return totalPages;
}
} }
async delete(id: string, deletedBy?: string) { async delete(id: string, deletedBy?: string) {
......
import { CrawlPageProcessorService } from "../crawl-page-processor.service"; import { CrawlPageProcessorService } from "../crawl-page-processor.service";
import { mapCrawlError } from "../../../common/helpers/error-mapping.helper"; import {
mapCrawlError,
isConnectionLossError,
} from "../../../common/helpers/error-mapping.helper";
import { import {
FirecrawlPageResult, FirecrawlPageResult,
CrawlErrorItem, CrawlErrorItem,
...@@ -233,6 +236,38 @@ describe("mapCrawlError()", () => { ...@@ -233,6 +236,38 @@ describe("mapCrawlError()", () => {
}); });
}); });
describe("isConnectionLossError()", () => {
it("detects timeout errors", () => {
expect(isConnectionLossError("request timed out")).toBe(true);
expect(isConnectionLossError("ETIMEDOUT 10.0.0.1")).toBe(true);
expect(isConnectionLossError("ESOCKETTIMEDOUT")).toBe(true);
expect(isConnectionLossError("Kết nối đến trang web đích bị quá thời gian (Timeout). Trang web phản hồi quá chậm.")).toBe(true);
});
it("detects connection resets and network errors", () => {
expect(isConnectionLossError("read ECONNRESET")).toBe(true);
expect(isConnectionLossError("connect ECONNREFUSED 127.0.0.1")).toBe(true);
expect(isConnectionLossError("socket hang up")).toBe(true);
expect(isConnectionLossError("network error occurred")).toBe(true);
expect(isConnectionLossError("Mất kết nối với máy chủ đích")).toBe(true);
});
it("detects DNS lookup errors", () => {
expect(isConnectionLossError("getaddrinfo ENOTFOUND api.example.com")).toBe(true);
expect(isConnectionLossError("dns lookup failure")).toBe(true);
expect(isConnectionLossError("không thể phân giải tên miền")).toBe(true);
});
it("returns false for non-connection errors", () => {
expect(isConnectionLossError(null)).toBe(false);
expect(isConnectionLossError(undefined)).toBe(false);
expect(isConnectionLossError("")).toBe(false);
expect(isConnectionLossError("blocked by robots.txt")).toBe(false);
expect(isConnectionLossError("captcha required")).toBe(false);
expect(isConnectionLossError("paywall detected")).toBe(false);
});
});
// ───────────────────────────────────────────── // ─────────────────────────────────────────────
// normalize() — status/errorMessage consistency // normalize() — status/errorMessage consistency
// ───────────────────────────────────────────── // ─────────────────────────────────────────────
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment