Harden Tahesab HTTP client against keep-alive hangs and retry health on alt ports.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-10-03 17:02:14 +03:30
co-authored by Cursor
parent 17628722d3
commit beae16437b
3 changed files with 164 additions and 52 deletions
@@ -252,20 +252,51 @@ export class TahesabConnectionsService {
const { row, api } = await this.getApi(organizationId, id);
try {
const result = await api.checkHealth();
const patch: {
lastHealthAt: Date;
lastHealthOk: boolean;
lastError: string | null;
baseUrl?: string;
allowInsecureTls?: boolean;
} = {
lastHealthAt: new Date(),
lastHealthOk: true,
lastError: null,
};
// اگر health فقط با آدرس/پروتکل جایگزین جواب داد، همان را ذخیره کن
if (
result.usedBaseUrl &&
result.usedBaseUrl.replace(/\/+$/, '') !== row.baseUrl.replace(/\/+$/, '')
) {
patch.baseUrl = result.usedBaseUrl.replace(/\/+$/, '');
}
// اگر فقط با TLS بدون verify جواب داد، فلگ را روشن کن تا بقیهٔ متدها هم کار کنند
if (!row.allowInsecureTls && result.usedInsecureTls) {
patch.allowInsecureTls = true;
}
const updated = await this.prisma.accountingConnection.update({
where: { id: row.id },
data: {
lastHealthAt: new Date(),
lastHealthOk: true,
lastError: null,
},
data: patch,
});
return { ok: true, connection: this.toDto(updated), health: result };
return {
ok: true,
connection: this.toDto(updated),
health: result,
adjusted:
Boolean(patch.baseUrl) || Boolean(patch.allowInsecureTls)
? {
baseUrl: patch.baseUrl,
allowInsecureTls: patch.allowInsecureTls,
}
: undefined,
};
} catch (err) {
const message = (err as Error).message ?? 'خطای ناشناخته';
const hint =
/مهلت اتصال|timeout/i.test(message) && !row.allowInsecureTls && /^https:/i.test(row.baseUrl)
? ' اگر گواهی ته‌حساب خودامضاست، «پذیرش گواهی SSL خودامضا» را فعال کنید.'
/مهلت اتصال|timeout/i.test(message)
? ' از سرور CRM به IP/پورت ته‌حساب دسترسی شبکه لازم است (فایروال). برای https خودامضا گزینه SSL را فعال کنید. پورت پیش‌فرض: https 8081 یا http 9550.'
: '';
const full = `${message}${hint}`.slice(0, 500);
const updated = await this.prisma.accountingConnection.update({
@@ -12,7 +12,7 @@ export type TahesabCallOptions = {
baseUrl: string;
token: string;
dbName: string;
/** مسیر OpenAPI مثل DoNewMoshtari یا CheckHealth-SSL */
/** مسیر OpenAPI مثل DoNewMoshtari یا CheckHealth-SSL؛ خالی = ریشه */
path: string;
/** کلید body؛ اگر خالی باشد فقط GET بدون body */
bodyKey?: string;
@@ -38,6 +38,32 @@ function extractTahesabError(json: unknown, text: string): string {
return trimmed ? trimmed.slice(0, 300) : '';
}
/** نامزدهای آدرس برای تست سلامت (پورت/پروتکل پیش‌فرض ته‌حساب) */
export function tahesabHealthBaseCandidates(baseUrl: string): string[] {
let u: URL;
try {
u = new URL(baseUrl.trim().replace(/\/+$/, ''));
} catch {
return [baseUrl.trim().replace(/\/+$/, '')];
}
const host = u.hostname;
const out: string[] = [`${u.protocol}//${u.host}`];
if (u.protocol === 'https:') {
out.push(`http://${host}:9550`);
out.push(`http://${host}:8081`);
if (u.port && u.port !== '9550' && u.port !== '8081') {
out.push(`http://${host}:${u.port}`);
}
} else {
out.push(`https://${host}:8081`);
out.push(`http://${host}:9550`);
if (u.port && u.port !== '8081' && u.port !== '9550') {
out.push(`https://${host}:${u.port}`);
}
}
return [...new Set(out.filter(Boolean))];
}
@Injectable()
export class TahesabHttpClient {
private readonly logger = new Logger(TahesabHttpClient.name);
@@ -45,17 +71,18 @@ export class TahesabHttpClient {
async call<T = unknown>(opts: TahesabCallOptions): Promise<T> {
const base = opts.baseUrl.replace(/\/+$/, '');
const path = (opts.path ?? '').replace(/^\/+/, '');
// Postman ته‌حساب برای health به ریشه می‌زند (بدون path)
const url = path ? new URL(`${base}/${path}`) : new URL(base);
const method = opts.method ?? (opts.bodyKey ? 'POST' : 'GET');
const timeoutMs = opts.timeoutMs ?? 30_000;
const label = path || '(root)';
const label = `${method} ${path || '(root)'} @ ${url.host}`;
const headers: Record<string, string> = {
Authorization: `Bearer ${opts.token}`,
DBname: opts.dbName,
DBName: opts.dbName,
Accept: 'application/json',
// بعضی نسخه‌های وب‌سرویس ویندوزی keep-alive را نمی‌بندند و Node تا timeout می‌ماند
Connection: 'close',
};
let body: string | undefined;
@@ -69,8 +96,10 @@ export class TahesabHttpClient {
const lib = isHttps ? https : http;
const agent =
isHttps && opts.allowInsecureTls
? new https.Agent({ rejectUnauthorized: false })
: undefined;
? new https.Agent({ rejectUnauthorized: false, keepAlive: false })
: isHttps
? new https.Agent({ keepAlive: false })
: new http.Agent({ keepAlive: false });
return new Promise<T>((resolve, reject) => {
let settled = false;
@@ -94,8 +123,20 @@ export class TahesabHttpClient {
},
(res) => {
const chunks: Buffer[] = [];
res.on('data', (c) => chunks.push(c));
res.on('end', () => {
let received = 0;
const expectedLen = Number(res.headers['content-length']);
let bodyDone = false;
const completeBody = () => {
if (bodyDone || settled) return;
bodyDone = true;
// بعد از خواندن کامل، سوکت را قطع کن تا hang روی keep-alive نماند
try {
res.destroy();
} catch {
/* ignore */
}
const text = Buffer.concat(chunks).toString('utf8');
let json: unknown = null;
if (text) {
@@ -121,7 +162,7 @@ export class TahesabHttpClient {
return;
}
this.logger.warn(
`Tahesab ${method} ${label} → ${status}: ${text.slice(0, 400)}`,
`Tahesab ${label} → ${status}: ${text.slice(0, 400)}`,
);
finish(() =>
reject(
@@ -134,7 +175,6 @@ export class TahesabHttpClient {
);
return;
}
// بعضی متدها با ۲۰۰ و فیلد ERROR برمی‌گردند
if (json && typeof json === 'object' && 'ERROR' in (json as object)) {
if (isEmptyOk) {
this.logger.debug(
@@ -153,11 +193,27 @@ export class TahesabHttpClient {
return;
}
finish(() => resolve(json as T));
};
res.on('data', (c: Buffer) => {
chunks.push(c);
received += c.length;
if (
Number.isFinite(expectedLen) &&
expectedLen > 0 &&
received >= expectedLen
) {
completeBody();
}
});
res.on('end', () => completeBody());
res.on('close', () => {
// اگر بادی آمده ولی end نیامده (سرور عجیب)، با دادهٔ موجود تمام کن
if (!settled && chunks.length) completeBody();
});
},
);
// مهلت از لحظهٔ شروع (شامل اتصال TCP/TLS) — timeout سوکت Node فقط بعد از connect است
const hardTimer = setTimeout(() => {
req.destroy();
finish(() =>
@@ -1,5 +1,9 @@
import { randomBytes } from 'crypto';
import type { TahesabHttpClient, TahesabCallOptions } from './tahesab-http.client';
import {
tahesabHealthBaseCandidates,
type TahesabHttpClient,
type TahesabCallOptions,
} from './tahesab-http.client';
export type ConnCreds = {
baseUrl: string;
@@ -178,46 +182,67 @@ export class TahesabApi {
}
/**
* تست سلامت مطابق Postman ته‌حساب:
* ابتدا ریشهٔ baseUrl، بعد /CheckHealth، در نهایت مسیر SSL یا No-SSL بر اساس پروتکل.
* تست سلامت مطابق Postman ته‌حساب (ریشه + CheckHealth).
* روی تایم‌اوت، پورت/پروتکل جایگزین (8081/9550) و در صورت نیاز TLS بدون verify را هم امتحان می‌کند.
*/
async checkHealth() {
const isHttps = /^https:/i.test(this.creds.baseUrl.trim());
const timeoutMs = 12_000;
async checkHealth(): Promise<{
Status?: string;
Api_Status?: string;
usedBaseUrl?: string;
usedPath?: string;
usedInsecureTls?: boolean;
}> {
const timeoutMs = 8_000;
type Health = { Status?: string; Api_Status?: string };
const attempts: Array<{
path: string;
bodyKey?: string;
method: 'GET' | 'POST';
}> = [
{ path: '', method: 'GET' },
{ path: '', bodyKey: 'CheckHealth', method: 'POST' },
{ path: 'CheckHealth', method: 'GET' },
{
path: isHttps ? 'CheckHealth-SSL' : 'CheckHealth-No-SSL',
method: 'GET',
},
];
const bases = tahesabHealthBaseCandidates(this.creds.baseUrl);
const insecureFlags = this.creds.allowInsecureTls
? [true]
: [false, true];
let lastErr: unknown;
for (const attempt of attempts) {
try {
return await this.call<Health>(
attempt.path,
attempt.bodyKey,
attempt.bodyKey ? [] : undefined,
attempt.method,
timeoutMs,
);
} catch (err) {
lastErr = err;
const msg = err instanceof Error ? err.message : String(err);
// خطای گواهی با تلاش‌های بعدی روی همان https حل نمی‌شود
if (/certificate|UNABLE_TO_VERIFY|self-signed|CERT_/i.test(msg)) {
throw err;
for (const baseUrl of bases) {
const isHttps = /^https:/i.test(baseUrl);
const pathAttempts: Array<{
path: string;
bodyKey?: string;
method: 'GET' | 'POST';
}> = [
{ path: '', method: 'GET' },
{ path: '', bodyKey: 'CheckHealth', method: 'POST' },
{ path: 'CheckHealth', method: 'GET' },
{
path: isHttps ? 'CheckHealth-SSL' : 'CheckHealth-No-SSL',
method: 'GET',
},
];
for (const allowInsecureTls of insecureFlags) {
if (!isHttps && allowInsecureTls) continue;
for (const attempt of pathAttempts) {
try {
const health = await this.http.call<Health>({
...this.creds,
baseUrl,
allowInsecureTls,
path: attempt.path,
bodyKey: attempt.bodyKey,
args: attempt.bodyKey ? [] : undefined,
method: attempt.method,
timeoutMs,
});
return {
...health,
usedBaseUrl: baseUrl,
usedPath: attempt.path || '(root)',
usedInsecureTls: allowInsecureTls,
};
} catch (err) {
lastErr = err;
}
}
}
}
throw lastErr instanceof Error
? lastErr
: new Error('تست اتصال ته‌حساب ناموفق بود.');