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:
@@ -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('تست اتصال تهحساب ناموفق بود.');
|
||||
|
||||
Reference in New Issue
Block a user