blog.apifirme.dev/fallback-apifirme-openapi-ro .md

blog / fiabilitate

Fallback de la apifirme la openapi.ro: când treci pe rezervă și când nu

Timeout scurt, rezervă doar la timeout, conexiune căzută și 5xx, niciodată la 404. Același adaptor în Python și TypeScript, cu null unde nu se știe.

· 15 min de citit

Cuprins
  1. Rezerva e pentru „nu știu acum”, nu pentru „nu”
  2. Același CUI, două răspunsuri
  3. Python: trei ieșiri posibile
  4. TypeScript: același contract, cu fetch
  5. Ce rămâne de făcut după rezervă
  6. De încercat

Pe scurt

  • Timeout scurt pe apifirme (aici 3 s) și un buget de timp pentru toată căutarea (5 s), din care trăiește și rezerva.
  • Treci pe openapi.ro la timeout, conexiune căzută și 5xx, inclusiv 503 source-unavailable; la 202 și 429 doar dacă Retry-After nu încape în buget. Niciodată la 400 sau 404.
  • Ambele răspunsuri ajung în aceeași structură, cu source marcat și cu null pentru ce openapi.ro nu spune: e-Factura, inactivitate, plata defalcată, CAEN.

Operatorul scrie CUI-ul clientului nou, apasă Tab și așteaptă denumirea. Dacă cererea către furnizorul de date stă agățată, el vede un formular care nu se mișcă și după zece secunde completează de mână, cu greșeli. Orice API extern are minute proaste, iar pentru o aplicație care nu poate factura fără datele clientului merită un al doilea drum: openapi.ro, alt furnizor românesc de date despre firme.

Rezerva e pentru „nu știu acum”, nu pentru „nu”

La fiecare cod de răspuns întrebi: ar putea alt furnizor să știe ceva în plus? Dacă apifirme nu a răspuns sau a spus că nu poate acum, da. Dacă a spus că firma nu există, nu: un CUI pe care ANAF nu îl cunoaște nu devine cunoscut pentru că întrebi în altă parte. Pe codurile din documentație:

Răspuns apifirme Ce înseamnă Ce faci
timeout, conexiune căzută niciun răspuns openapi.ro
500, 502, 504 eroare la furnizor sau pe drum openapi.ro
503 source-unavailable firma nu e încă în bază și ANAF nu poate fi întrebat acum openapi.ro
202, 429 firma e căutată la ANAF; limita pe minut sau pe lună aștepți Retry-After o dată, dacă încape în buget; altfel openapi.ro
404 CUI neînregistrat sau persoană fizică răspuns final
400 CUI invalid îl prinzi înainte, local
401, 403 cheia sau planul eroare de configurare, vizibilă

404 vine și pentru PFA, II și IF (company-not-available): apifirme nu servește date despre persoane fizice, iar aplicația ta are oricum nevoie de un drum pentru ele, de obicei completarea de mână. 401 „rezolvat” cu rezerva înseamnă că afli de cheia greșită peste o lună, din loguri.

202 și 429 vin cu Retry-After și nu se numără ca cereri; diferența e cine așteaptă. Un job de noapte poate dormi 12 secunde, un formular nu. Codul așteaptă deci o singură dată și doar dacă pauza încape în bugetul cererii: cu 5 s, un Retry-After: 12 înseamnă openapi.ro; cu 60 s, o pauză.

Timeout-ul îl alegi după răbdarea omului din fața ecranului, minus ce îi trebuie rezervei. Când ANAF întârzie, apifirme răspunde cu 202 în loc să țină cererea deschisă, deci timeout-ul nu trebuie să acopere și căutarea la ANAF. Aici: 1,5 s pentru conectare, 3 s pentru răspuns, 5 s pentru tot.

Același CUI, două răspunsuri

Pe aceeași firmă de exemplu, cele două API-uri spun aproape aceleași lucruri, cu alte nume și tipuri:

$ curl -s -H "Authorization: Bearer $APIFIRME_KEY" "$APIFIRME_BASE/companies/90000057" \
    | jq '{judet, scp_tva, tva_incasare, split_tva, status_inactiv, e_factura}'
{
  "judet": "IF",
  "scp_tva": true,
  "tva_incasare": true,
  "split_tva": false,
  "status_inactiv": false,
  "e_factura": true
}
$ curl -s -H "x-api-key: $OPENAPI_RO_KEY" "$OPENAPI_RO_BASE/companies/90000057" \
    | jq '{judet, tva, tva_la_incasare, radiata, stare}'
{
  "judet": "Ilfov",
  "tva": "2012-06-24",
  "tva_la_incasare": [
    {
      "tip": "I",
      "de_la": "2026-09-01",
      "pana_la": null,
      …
    }
  ],
  "radiata": false,
  "stare": "INREGISTRAT din data 25.03.2012"
}
  • Cheia merge în x-api-key; cif e text, nu număr.
  • judet e numele („Ilfov”), nu codul (IF). Adaptorul îl traduce, ca să nu ai două feluri de județe în aceeași coloană.
  • tva e data înregistrării în scopuri de TVA sau null; din el iese platitor_tva.
  • tva_la_incasare e o listă de perioade, nu da/nu. Am considerat că firma aplică azi TVA la încasare dacă are o perioadă de tip I care cuprinde ziua de azi; verifică interpretarea pe câteva firme cunoscute.
  • 202 are în corp retry_after, un moment ISO 8601, nu secunde.
  • 404 vine și pentru un CIF invalid, deci cifra de control se verifică înainte, local; altfel o greșeală de tastare arată ca o firmă inexistentă.

Fără echivalent clar în răspunsul lor rămân e_factura, status_inactiv, split_tva, caen (există caen_code la bilanțuri, nu la firmă), euid, forma_juridica_cod și sources. În structura comună ele devin null, adică „necunoscut”. Nu false: un false spune „am verificat și nu e inactivă”, iar asta nu a verificat nimeni.

Python: trei ieșiri posibile

Cu requests. firma() se termină în trei feluri: o structură cu source, FirmaNegasita (un răspuns, nu o pană) sau FirmaIndisponibila (niciun furnizor nu a răspuns la timp). 400, 401 și 403 ies ca excepții obișnuite, ca să le vadă cineva.

firma.pyPython
"""Datele unei firme de la apifirme, cu openapi.ro ca rezervă."""
import json, os, re, sys, time, unicodedata
from datetime import date, datetime, timezone

import requests

APIFIRME_BASE = os.environ.get("APIFIRME_BASE", "https://apifirme.dev/rest/v1")
OPENAPI_RO_BASE = os.environ.get("OPENAPI_RO_BASE", "https://api.openapi.ro/api")


class CuiInvalid(Exception): pass
class FirmaNegasita(Exception): pass        # 404: un răspuns, nu o pană
class FirmaIndisponibila(Exception): pass   # niciun furnizor nu a răspuns la timp
class _Rezerva(Exception): pass             # apifirme a eșuat; motivul ajunge în log


def cui_valid(text):
    s = str(text).strip().upper().removeprefix("RO").strip()
    if not re.fullmatch(r"[0-9]{2,10}", s):
        return None
    suma = sum(int(c) * int(p) for c, p in zip(s[:-1].rjust(9, "0"), "753217532"))
    return int(s) if suma * 10 % 11 % 10 == int(s[-1]) else None


def ramas(limita):
    return limita - time.monotonic()


def firma(text, buget=5.0):
    cui = cui_valid(text)
    if cui is None:
        raise CuiInvalid(text)   # nu trimitem gunoi la niciunul dintre furnizori
    limita = time.monotonic() + buget
    try:
        return din_apifirme(cui, limita)
    except _Rezerva as e:
        print(f"apifirme: {e}, după {buget - ramas(limita):.1f} s; întreb openapi.ro",
              file=sys.stderr)
    return din_openapi_ro(cui, limita)


def din_apifirme(cui, limita):
    cheie = {"Authorization": f"Bearer {os.environ['APIFIRME_KEY']}"}
    for incercare in (1, 2):
        try:
            r = requests.get(f"{APIFIRME_BASE}/companies/{cui}", headers=cheie,
                             timeout=(1.5, max(0.1, min(3.0, ramas(limita) - 1))))
        except requests.Timeout:
            raise _Rezerva("timeout")
        except requests.ConnectionError as e:
            raise _Rezerva(f"conexiune căzută: {type(e).__name__}")
        if r.status_code == 200:
            return normalizeaza_apifirme(r.json())
        if r.status_code == 404:
            raise FirmaNegasita(tip_problema(r))
        if r.status_code in (202, 429):
            pauza = float(r.headers.get("Retry-After", 2))
            if incercare == 1 and pauza + 1.5 < ramas(limita):
                time.sleep(pauza)
                continue
            raise _Rezerva(f"{r.status_code}, Retry-After {pauza:g} s nu încape în buget")
        if r.status_code >= 500:
            raise _Rezerva(f"{r.status_code} {tip_problema(r)}".strip())
        r.raise_for_status()  # 400, 401, 403: greșeala e la noi; rezerva ar ascunde-o
    raise _Rezerva("tot 202 după o pauză")


def tip_problema(r):
    """Ultima parte din `type` (RFC 7807), de exemplu company-not-found."""
    try:
        return r.json()["type"].rsplit("/", 1)[-1]
    except (ValueError, KeyError, TypeError, AttributeError):
        return ""


def din_openapi_ro(cui, limita):
    for incercare in (1, 2):
        if ramas(limita) < 0.5:
            raise FirmaIndisponibila("s-a terminat bugetul de timp")
        try:
            r = requests.get(f"{OPENAPI_RO_BASE}/companies/{cui}",
                             headers={"x-api-key": os.environ["OPENAPI_RO_KEY"],
                                      "accept": "application/json"},
                             timeout=(1.5, ramas(limita)))
        except requests.RequestException as e:
            raise FirmaIndisponibila(f"nici openapi.ro: {type(e).__name__}") from e
        if r.status_code == 200:
            return normalizeaza_openapi_ro(r.json())
        if r.status_code == 404:
            raise FirmaNegasita("openapi.ro: 404")
        if r.status_code == 202:
            # retry_after este un moment ISO 8601, nu un număr de secunde; marja
            # acoperă ceasurile care nu bat la fel la tine și la ei
            text = r.json()["retry_after"]
            moment = datetime.fromisoformat(text.replace("Z", "+00:00"))
            pauza = (moment - datetime.now(timezone.utc)).total_seconds() + 0.25
            pauza = max(0.5, pauza)
            if incercare == 1 and pauza + 1 < ramas(limita):
                time.sleep(pauza)
                continue
            raise FirmaIndisponibila("openapi.ro: firma e încă în coada lor")
        raise FirmaIndisponibila(f"nici openapi.ro: HTTP {r.status_code}")
    raise FirmaIndisponibila("openapi.ro: tot 202 după o pauză")


def normalizeaza_apifirme(d):
    return {
        "cui": d["cui"], "denumire": d["denumire"], "nr_reg_com": d["nr_reg_com"],
        "adresa": d["adresa_completa"], "judet": d["judet"],
        "cod_postal": d["cod_postal"],
        "platitor_tva": d["scp_tva"], "tva_la_incasare": d["tva_incasare"],
        "split_tva": d["split_tva"], "inactiv": d["status_inactiv"],
        "radiata": None if d["stare"] is None else d["stare"] == "RADIERE",
        "e_factura": d["e_factura"], "caen": d["caen"], "source": "apifirme",
    }


def normalizeaza_openapi_ro(d):
    return {
        "cui": int(d["cif"]), "denumire": d["denumire"], "nr_reg_com": d["numar_reg_com"],
        "adresa": d["adresa"], "judet": cod_judet(d["judet"]),
        "cod_postal": d["cod_postal"],
        "platitor_tva": d["tva"] is not None,     # tva = data înregistrării sau null
        "tva_la_incasare": la_incasare_azi(d.get("tva_la_incasare")),
        # fără echivalent în răspunsul lor: necunoscut, nu „nu”
        "split_tva": None, "inactiv": None, "e_factura": None, "caen": None,
        "radiata": d["radiata"], "source": "openapi.ro",
    }


def la_incasare_azi(perioade):
    if perioade is None:
        return None
    azi = date.today().isoformat()
    return any(p.get("tip") == "I" and p["de_la"] <= azi <= (p.get("pana_la") or "9999")
               for p in perioade)


JUDETE = dict(x.split(" ", 1)[::-1] for x in (
    "AB ALBA|AR ARAD|AG ARGES|BC BACAU|BH BIHOR|BN BISTRITA-NASAUD|BT BOTOSANI|BR BRAILA|"
    "BV BRASOV|BZ BUZAU|CL CALARASI|CS CARAS-SEVERIN|CJ CLUJ|CT CONSTANTA|CV COVASNA|"
    "DB DAMBOVITA|DJ DOLJ|GL GALATI|GR GIURGIU|GJ GORJ|HR HARGHITA|HD HUNEDOARA|"
    "IL IALOMITA|IS IASI|IF ILFOV|MM MARAMURES|MH MEHEDINTI|MS MURES|NT NEAMT|OT OLT|"
    "PH PRAHOVA|SJ SALAJ|SM SATU MARE|SB SIBIU|SV SUCEAVA|TR TELEORMAN|TM TIMIS|"
    "TL TULCEA|VL VALCEA|VS VASLUI|VN VRANCEA|B BUCURESTI").split("|"))


def cod_judet(nume):
    """openapi.ro dă numele județului („Sibiu”), apifirme codul („SB”)."""
    if not nume:
        return None
    n = unicodedata.normalize("NFKD", nume).encode("ascii", "ignore").decode().upper()
    return JUDETE.get(n.removeprefix("MUNICIPIUL ").strip())


if __name__ == "__main__":
    try:
        buget = float(sys.argv[2]) if len(sys.argv) > 2 else 5.0
        print(json.dumps(firma(sys.argv[1], buget), ensure_ascii=False, indent=2))
    except (CuiInvalid, FirmaNegasita, FirmaIndisponibila) as e:
        sys.exit(f"{type(e).__name__}: {e}")

Timeout-ul de citire din requests se aplică fiecărei citiri de pe socket, nu întregii cereri; pentru un formular ajunge. Validarea locală ține gunoiul departe de ambii furnizori: /validate/cui face același lucru fără cheie, dar e tot o cerere la apifirme, deci nu ajută tocmai când apifirme nu răspunde. Marja de 0,25 s la retry_after vine din test: dormind exact până la momentul primit, prima versiune ajungea uneori cu câteva milisecunde prea devreme și primea încă un 202.

Am rulat codul pe un server local care imită ambele API-uri și se comută din mers ($MOCK; firmele „EXEMPLU … SRL” sunt date de exemplu). Fiecare fel în care cade apifirme duce la openapi.ro:

$ for s in slow down 503 429; do
>   curl -s -X POST "$MOCK/_mock/scenario?apifirme=$s&openapi=ok" > /dev/null
>   python3 firma.py 90000057 2>&1 | grep -E '^apifirme|"source"'
> done
apifirme: timeout, după 3.0 s; întreb openapi.ro
  "source": "openapi.ro"
apifirme: conexiune căzută: ConnectionError, după 0.0 s; întreb openapi.ro
  "source": "openapi.ro"
apifirme: 503 source-unavailable, după 0.0 s; întreb openapi.ro
  "source": "openapi.ro"
apifirme: 429, Retry-After 12 s nu încape în buget, după 0.0 s; întreb openapi.ro
  "source": "openapi.ro"

Cazurile în care rezerva nu are voie să pornească le-am rulat cu openapi.ro pus pe „căzut”: dacă programul l-ar fi întrebat, rezultatul ar fi fost FirmaIndisponibila.

$ curl -s -X POST "$MOCK/_mock/scenario?apifirme=ok&openapi=down" > /dev/null
$ python3 firma.py 90000030
FirmaNegasita: company-not-available
$ python3 firma.py 90000049
FirmaNegasita: company-not-found
$ python3 firma.py 90000058
CuiInvalid: 90000058

La 202 decide bugetul. Firma 90000081 e „căutată la ANAF” la prima cerere, cu Retry-After: 2: cu 5 s de buget programul așteaptă și primește răspunsul de la apifirme, cu 3 s trece pe rezervă:

$ curl -s -X POST "$MOCK/_mock/scenario?apifirme=pending&openapi=ok" > /dev/null
$ time python3 firma.py 90000081 | grep source
  "source": "apifirme"
real	0m2,070s
$ curl -s -X POST "$MOCK/_mock/scenario?apifirme=pending&openapi=ok" > /dev/null
$ python3 firma.py 90000081 3 | grep source
apifirme: 202, Retry-After 2 s nu încape în buget, după 0.0 s; întreb openapi.ro
  "source": "openapi.ro"

Când nu răspunde niciunul (ambele servere de test puse pe „lent”), FirmaIndisponibila vine după 5,07 s, la capătul bugetului, nu după două timeout-uri lungi puse cap la cap.

TypeScript: același contract, cu fetch

Node 18 sau mai nou, fără dependențe; aceeași structură, aceleași excepții.

firma.tsTypeScript
// firma.ts — apifirme, cu openapi.ro ca rezervă. Node 18+, fără dependențe.
const APIFIRME_BASE = process.env.APIFIRME_BASE ?? "https://apifirme.dev/rest/v1";
const OPENAPI_RO_BASE = process.env.OPENAPI_RO_BASE ?? "https://api.openapi.ro/api";

type DaNu = boolean | null; // null = necunoscut, nu „nu”
export interface Firma {
  cui: number; denumire: string | null; nr_reg_com: string | null;
  adresa: string | null; judet: string | null; cod_postal: string | null;
  platitor_tva: DaNu; tva_la_incasare: DaNu; split_tva: DaNu;
  inactiv: DaNu; radiata: DaNu; e_factura: DaNu; caen: number | null;
  source: "apifirme" | "openapi.ro";
}

export class CuiInvalid extends Error {}
export class FirmaNegasita extends Error {}
export class FirmaIndisponibila extends Error {}
class Rezerva extends Error {}

const sleep = (s: number) => new Promise((ok) => setTimeout(ok, s * 1000));
const ramas = (limita: number) => (limita - Date.now()) / 1000;

export function cuiValid(text: string): number | null {
  const s = text.trim().toUpperCase().replace(/^RO\s*/, "");
  if (!/^[0-9]{2,10}$/.test(s)) return null;
  const corp = s.slice(0, -1).padStart(9, "0");
  const suma = [...corp].reduce((acc, c, i) => acc + +c * +"753217532"[i], 0);
  return ((suma * 10) % 11) % 10 === +s.slice(-1) ? +s : null;
}

async function cerere(url: string, headers: Record<string, string>, sec: number) {
  try {
    // timeout() vrea milisecunde întregi și taie toată cererea, inclusiv corpul
    const ms = Math.max(100, Math.round(sec * 1000));
    return await fetch(url, { headers, signal: AbortSignal.timeout(ms) });
  } catch (e) {
    const err = e as Error & { cause?: { code?: string } };
    throw new Error(err.name === "TimeoutError" ? "timeout"
      : `conexiune căzută: ${err.cause?.code ?? err.message}`);
  }
}

async function tipProblema(r: Response): Promise<string> {
  const corp = await r.json().catch(() => ({}));
  return String(corp?.type ?? "").split("/").pop() ?? "";
}

export async function firma(text: string, bugetSec = 5): Promise<Firma> {
  const cui = cuiValid(text);
  if (cui === null) throw new CuiInvalid(text);
  const limita = Date.now() + bugetSec * 1000;
  try {
    return await dinApifirme(cui, limita);
  } catch (e) {
    if (!(e instanceof Rezerva)) throw e;
    const dupa = (bugetSec - ramas(limita)).toFixed(1);
    console.error(`apifirme: ${e.message}, după ${dupa} s; întreb openapi.ro`);
  }
  return dinOpenapiRo(cui, limita);
}

async function dinApifirme(cui: number, limita: number): Promise<Firma> {
  const cheie = { Authorization: `Bearer ${process.env.APIFIRME_KEY}` };
  for (const incercare of [1, 2]) {
    const r = await cerere(`${APIFIRME_BASE}/companies/${cui}`, cheie,
      Math.min(3, ramas(limita) - 1)).catch((e) => { throw new Rezerva(e.message); });
    if (r.status === 200) return normalizeazaApifirme(await r.json());
    if (r.status === 404) throw new FirmaNegasita(await tipProblema(r));
    if (r.status === 202 || r.status === 429) {
      const pauza = Number(r.headers.get("retry-after") ?? 2);
      if (incercare === 1 && pauza + 1.5 < ramas(limita)) {
        await sleep(pauza);
        continue;
      }
      throw new Rezerva(`${r.status}, Retry-After ${pauza} s nu încape în buget`);
    }
    if (r.status >= 500) throw new Rezerva(`${r.status} ${await tipProblema(r)}`);
    // 400, 401, 403: greșeala e la noi; rezerva ar ascunde-o
    throw new Error(`apifirme: HTTP ${r.status} ${await tipProblema(r)}`);
  }
  throw new Rezerva("tot 202 după o pauză");
}

async function dinOpenapiRo(cui: number, limita: number): Promise<Firma> {
  const cheie = { "x-api-key": process.env.OPENAPI_RO_KEY ?? "", accept: "application/json" };
  for (const incercare of [1, 2]) {
    if (ramas(limita) < 0.5) throw new FirmaIndisponibila("s-a terminat bugetul de timp");
    const r = await cerere(`${OPENAPI_RO_BASE}/companies/${cui}`, cheie, ramas(limita))
      .catch((e) => { throw new FirmaIndisponibila(`nici openapi.ro: ${e.message}`); });
    if (r.status === 200) return normalizeazaOpenapiRo(await r.json());
    if (r.status === 404) throw new FirmaNegasita("openapi.ro: 404");
    if (r.status === 202) {
      // retry_after e un moment ISO 8601; marja acoperă ceasurile nesincronizate
      const { retry_after } = await r.json();
      const pauza = Math.max(0.5,
        (Date.parse(retry_after) - Date.now()) / 1000 + 0.25);
      if (incercare === 1 && pauza + 1 < ramas(limita)) {
        await sleep(pauza);
        continue;
      }
      throw new FirmaIndisponibila("openapi.ro: firma e încă în coada lor");
    }
    throw new FirmaIndisponibila(`nici openapi.ro: HTTP ${r.status}`);
  }
  throw new FirmaIndisponibila("openapi.ro: tot 202 după o pauză");
}

function normalizeazaApifirme(d: any): Firma {
  return {
    cui: d.cui, denumire: d.denumire, nr_reg_com: d.nr_reg_com,
    adresa: d.adresa_completa, judet: d.judet, cod_postal: d.cod_postal,
    platitor_tva: d.scp_tva, tva_la_incasare: d.tva_incasare,
    split_tva: d.split_tva, inactiv: d.status_inactiv,
    radiata: d.stare == null ? null : d.stare === "RADIERE",
    e_factura: d.e_factura, caen: d.caen, source: "apifirme",
  };
}

function normalizeazaOpenapiRo(d: any): Firma {
  return {
    cui: Number(d.cif), denumire: d.denumire, nr_reg_com: d.numar_reg_com,
    adresa: d.adresa, judet: codJudet(d.judet), cod_postal: d.cod_postal,
    platitor_tva: d.tva != null, // tva = data înregistrării sau null
    tva_la_incasare: laIncasareAzi(d.tva_la_incasare),
    split_tva: null, inactiv: null, e_factura: null, caen: null, // fără echivalent
    radiata: d.radiata ?? null, source: "openapi.ro",
  };
}

type Perioada = { tip: string; de_la: string; pana_la: string | null };

function laIncasareAzi(perioade?: Perioada[] | null): DaNu {
  if (perioade == null) return null;
  const azi = new Date().toISOString().slice(0, 10);
  return perioade.some(
    (p) => p.tip === "I" && p.de_la <= azi && azi <= (p.pana_la ?? "9999"));
}

const JUDETE = new Map(
  ("AB ALBA|AR ARAD|AG ARGES|BC BACAU|BH BIHOR|BN BISTRITA-NASAUD|BT BOTOSANI|" +
    "BR BRAILA|BV BRASOV|BZ BUZAU|CL CALARASI|CS CARAS-SEVERIN|CJ CLUJ|CT CONSTANTA|" +
    "CV COVASNA|DB DAMBOVITA|DJ DOLJ|GL GALATI|GR GIURGIU|GJ GORJ|HR HARGHITA|" +
    "HD HUNEDOARA|IL IALOMITA|IS IASI|IF ILFOV|MM MARAMURES|MH MEHEDINTI|MS MURES|" +
    "NT NEAMT|OT OLT|PH PRAHOVA|SJ SALAJ|SM SATU MARE|SB SIBIU|SV SUCEAVA|" +
    "TR TELEORMAN|TM TIMIS|TL TULCEA|VL VALCEA|VS VASLUI|VN VRANCEA|B BUCURESTI")
    .split("|").map((x) => [x.slice(x.indexOf(" ") + 1), x.split(" ")[0]]),
);

function codJudet(nume: string | null): string | null {
  if (!nume) return null; // openapi.ro dă numele („Sibiu”), apifirme codul („SB”)
  const n = nume.normalize("NFD").replace(/\p{M}/gu, "").toUpperCase();
  return JUDETE.get(n.replace(/^MUNICIPIUL /, "").trim()) ?? null;
}

// npx tsx firma.ts RO13548146 [buget]
if (process.argv[1]?.endsWith("firma.ts")) {
  firma(process.argv[2] ?? "", Number(process.argv[3] ?? 5)).then(
    (f) => {
      console.log(JSON.stringify(f, null, 2));
      process.exit(0); // nu aștepta conexiunile abandonate
    },
    (e) => {
      console.error(`${e.constructor.name}: ${e.message}`);
      process.exit(1);
    });
}

AbortSignal.timeout() taie toată cererea, inclusiv citirea corpului, dar vrea milisecunde întregi. Prima versiune îi dădea 1980.9999999999998, primea The value of "delay" is out of range, iar eroarea, prinsă ca „conexiune căzută”, trimitea totul pe rezervă; a scos-o la iveală doar scenariul cu 202. Tot din teste a ieșit că, la o adresă care nu răspunde deloc, rezultatul vine după 3 s, dar procesul se închidea după 10: conectarea abandonată rămâne în bucla de evenimente până la timeout-ul propriu al clientului HTTP din Node. Într-un server nu contează; în linia de comandă, programul iese explicit.

Firma de exemplu 90000014 e inactivă fiscal. Întâi cu apifirme căzut și openapi.ro răspunzând cu 202, apoi cu 200; apoi direct prin apifirme:

$ curl -s -X POST "$MOCK/_mock/scenario?apifirme=down&openapi=pending" > /dev/null
$ time npx tsx firma.ts 90000014 | grep -E 'inactiv|e_factura|source'
apifirme: conexiune căzută: UND_ERR_SOCKET, după 0.0 s; întreb openapi.ro
  "inactiv": null,
  "e_factura": null,
  "source": "openapi.ro"
real	0m2,385s
$ curl -s -X POST "$MOCK/_mock/scenario?apifirme=ok&openapi=ok" > /dev/null
$ npx tsx firma.ts 90000014 | grep -E 'inactiv|e_factura|source'
  "inactiv": true,
  "e_factura": false,
  "source": "apifirme"

Dacă adaptorul ar fi completat câmpul lipsă cu false, programul de facturare ar fi emis liniștit o factură unui client inactiv. Cu null poate decide singur: blochează, cere confirmare sau reverifică mai târziu.

Ce rămâne de făcut după rezervă

  • Nu suprascrie date bune cu null. Un răspuns venit pe rezervă actualizează în baza ta doar câmpurile care nu sunt null.
  • Reverifică ce a venit pe rezervă. Păstrează source lângă înregistrare și, înainte de factură, întreabă din nou apifirme, de exemplu prin invoice-check, care răspunde exact la inactivitate, TVA și e-Factura.
  • Nu plăti timeout-ul la fiecare cerere. Dacă apifirme nu răspunde un minut, fiecare căutare așteaptă 3 s până la rezervă. Un întrerupător (circuit breaker) ține minte eșecurile și trimite direct la openapi.ro o vreme; variantele pentru Laravel și .NET din seria aceasta au unul. Iar dacă linia de log apare des, uită-te la timeout-ul tău, la rețea și la pagina de stare.

De încercat

Rulează programul pe un CUI din facturile de luna trecută, apoi pune în APIFIRME_BASE o adresă care nu răspunde deloc, de exemplu http://10.255.255.1/rest/v1. La noi, rezerva a pornit după 1,5 s în Python (timeout-ul de conectare) și după 3 s în TypeScript. Dacă la tine durează mai mult decât are răbdare cel din fața formularului, scade timeout-urile, nu mări bugetul.

etichete: fallback, openapi.ro, Python, timeout, TypeScript

Încearcă datele pe firmele tale

Scrie un CUI în consolă și vezi exact ce răspunde API-ul, fără cont. Contul gratuit se face cu adresa de e-mail și un cod, fără parolă, și îți dă o cheie pentru cod.

creează cont gratuit consola, fără cont documentația prețuri