Error model¶
anafpy deliberately splits "something went wrong" into two channels. Knowing the split is the difference between robust and subtly broken integration code — read this page before shipping.
Exceptions: transport, auth, programming errors¶
The AnafError hierarchy covers failures of the machinery:
AnafAuthError— the OAuth layer could not produce a usable token.AnafTransportError— the request never completed (connection, timeout).AnafResponseError— ANAF answered, but not in a shape anafpy accepts.AnafRateLimitError— HTTP 429, exposingretry_after. The client does not auto-back-off; scheduling the retry is yours.AnafWafRejectionError— ANAF's firewall refused the request body; carries the block page'ssupport_id. See below.AnafDownloadExpiredError— the message left ANAF's 60-day download window; carriesmessage_id. Terminal. See below.AnafConfigError— configuration problems (missing credentials, bad env).
AnafRateLimitError, AnafWafRejectionError and AnafDownloadExpiredError all
subclass AnafResponseError, so code that already catches that keeps working.
The firewall block page¶
ANAF fronts webservicesp.anaf.ro with a WAF that scans the invoice XML you post
to validare/transformare, and answers a Request Rejected HTML page with
HTTP 200 when the document matches an attack signature. Real, ANAF-accepted
invoices do — a relative path in xsi:schemaLocation looks like path traversal,
a ;CP in a street address looks like a shell command.
anafpy never hands that page back as if it were a result: it raises
AnafWafRejectionError (in the shared transport, so every client is covered).
It also defuses what can be defused — xsi:schemaLocation is advisory and is
dropped before posting, and a render_invoice_pdf(validate=False) that gets
blocked is retried once on the validating path, which is not subject to the same
policy. You see a warning when that happens, and the PDF you asked for.
The closed download window¶
ANAF keeps an e-Factura message downloadable for 60 days. Past that, descarcare
answers HTTP 200 with {"eroare": "Fisierul nu mai poate fi descarcat pentru
ca a trecut perioada de 60 de zile in care este disponibil", …} instead of the
ZIP — see the reference §4.1.
download raises AnafDownloadExpiredError for exactly that note, with the id
on message_id. It means stop: the file is gone from the SPV, and no retry
will ever succeed. Every other non-ZIP body — an unknown id, a malformed request
— stays a plain AnafResponseError, which is worth retrying or fixing. The
recognizer under-matches on purpose (it reads the parsed eroare field and one
accent-insensitive core phrase), because a false positive would make an archiver
drop an invoice for good.
You meet this in normal operation, not just through caller error: listaMesaje
and descarcare anchor their 60 days differently, so ANAF lists messages it then
refuses to hand over. A first sync — or one catching up after a gap — with a
60-day lookback lands in that boundary band:
try:
message = await client.download(item.id)
except AnafDownloadExpiredError:
mark_permanently_unavailable(item.id) # never ask again
Typed values: business outcomes¶
Outcomes of the filing itself are returned, never raised:
- an upload rejection (e-Factura
nok, BR-RO findings) isUploadResult.accepted is Falsewith the findings attached; - a rejected message's processing state is
MessageStatus.state; - a validation verdict is a
RemoteValidationResult.
The rationale: a rejection is a successful API call telling you something about your document. Code that catches exceptions to handle rejections conflates "ANAF said no" with "the network is down".
The 200-with-error-note split¶
ANAF's listing endpoints (e-Factura list_messages / list_notifications,
e-Transport info) overload a single response note — e-Factura's eroare,
e-Transport's Errors[].errorMessage (for info, also a top-level error
string) — for both "no results in this window" and genuine errors. anafpy
classifies the note:
- a no-results note yields an empty iterator (for
info: an emptyInfoListwith the note preserved in.error); - a genuine error raises
AnafResponseError(withstatus_code=200).
So an empty loop body means "nothing there", and you don't have to parse Romanian error strings yourself.
No transport retry (one documented exception)¶
Every discrete method on the OAuth and public clients makes exactly one HTTP
call: one call, one result-or-raise. This is a hard rule so the non-idempotent
upload POST is never silently repeated. Bring your own retry policy (and make
it idempotency-aware). The built-in upload_and_wait loop polls on the
business "still processing" state only — a transport error inside it
propagates immediately.
Two deliberate exceptions. The SPV client: its reads (list_messages,
download_document) retry transient network failures with backoff, because
every SPV operation is an idempotent GET. Received HTTP answers — including
429 — still surface immediately, and request_report stays single-shot. And
render_invoice_pdf(validate=False): a firewall block on the skip-validation URL
is followed by one attempt at the validating URL (a different endpoint, on a
stateless public service that files nothing) instead of losing the PDF.