Commit ffc94a49 authored by ThinhNC's avatar ThinhNC

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

parent 85f64564
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 {
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 (
err.includes("unauthorized") ||
err.includes("api key") ||
err.includes("apikey") ||
err.includes("credit") ||
err.includes("402") ||
(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ụ).";
......@@ -106,3 +108,64 @@ export function getErrorMessage(error: unknown): string {
}
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", () => {
}),
);
});
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";
import { CrawlJobStatus, CrawlMode, LogLevel, Prisma } from "@prisma/client";
import { CrawlJobQueryDto } from "./crawl-job.dto";
import { JOB_STATUS } from "../../common/constants/job-status.constant";
import { isConnectionLossError } from "../../common/helpers/error-mapping.helper";
export class CrawlJobRepository {
create(data: {
......@@ -332,13 +333,36 @@ export class CrawlJobRepository {
]);
}
countJobsSince(userId: string, sinceDate: Date): Promise<number> {
return prisma.crawlJob.count({
async countJobsSince(userId: string, sinceDate: Date): Promise<number> {
const totalCount = await prisma.crawlJob.count({
where: {
userId,
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(
......@@ -361,7 +385,33 @@ export class CrawlJobRepository {
where: { userId },
_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) {
......
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 {
FirecrawlPageResult,
CrawlErrorItem,
......@@ -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
// ─────────────────────────────────────────────
......
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