Python
Синхронный и асинхронный клиенты, понятные исключения, повторы при перегрузке. Python 3.9+, одна зависимость — httpx.
| Пакет | Версии Python | Зависимости |
|---|---|---|
pdfapi | 3.9+ | httpx |
Установка#
pip install pdfapiКлюч берётся из переменной окружения PDFAPI_KEY — выпустите его в кабинете:
export PDFAPI_KEY=pdf_live_...Первый PDF#
from pdfapi import PdfApi
client = PdfApi() # ключ из PDFAPI_KEY
pdf = client.html("<h1>Счёт №42</h1><p>К оплате: 12 000 ₽</p>", margin_top="20mm")
pdf.save("invoice.pdf")
print(pdf.pages) # 1 — сколько страниц списано
print(pdf.quota) # Quota(limit=10000, used=143, remaining=9857)Методы#
| Метод | Что делает |
|---|---|
client.html(html, **параметры) | HTML → PDF |
client.url(url, **параметры) | публичная страница по адресу → PDF. Тариф Premium и выше; адрес — полный, с https:// |
client.markdown(text, **параметры) | Markdown → PDF. Тариф Premium и выше |
client.office(file, filename=None, landscape=None) | docx, xlsx, pptx → PDF. Тариф Premium и выше |
client.convert(body) | POST /v1/convert с телом как есть — для всего остального |
client.me() | тариф, расход и остаток страниц — GET /v1/me |
У каждого метода есть timeout= — секунд на этот вызов.
Параметры страницы#
Именованные аргументы, указывать их необязательно. Что они значат и как сочетаются, показано в разделе Параметры страницы.
| Параметр | Пример | По умолчанию |
|---|---|---|
landscape | True | False |
paper_width, paper_height | "8.27in", "297mm" | A4 |
margin_top, margin_bottom, margin_left, margin_right | "20mm" | "0.39in" |
wait_delay | "2s", "500ms", до 10 секунд | "0s" |
pdf = client.url("https://example.com/report", landscape=True, wait_delay="1s")Размер — число и единица pt, px, in, mm, cm или pc без пробела. 20, "20" и "20 mm" вызовут ValueError сразу, без запроса к API.
Документы Office#
pdf = client.office("report.docx") # путь
pdf = client.office(data, filename="report.docx") # bytes — нужно имя с расширением
with open("table.xlsx", "rb") as f:
pdf = client.office(f, landscape=True) # открытый файлФайл — до 3,7 МБ: в base64 он увеличивается на треть, а тело запроса ограничено 5 МБ. Библиотека проверяет, что файл — настоящий ZIP-архив, поэтому старый .doc или PDF, переименованный в .docx, отклоняется до отправки.
Результат#
PdfResult:
content— байты PDF (то же —bytes(pdf));pages— сколько страниц списано;quota—Quota(limit, used, remaining)после этой конвертации;save(path)— записать в файл и вернуть путь.
save пишет атомарно: сначала во временный файл рядом, потом переименовывает поверх него. Оборвётся запись или кончится место — останется прежний файл, а не обрезанный PDF. Каталог не создаётся: опечатка в пути влечёт за собой ошибку.
Аккаунт и квота#
me = client.me()
print(me.plan, me.pages_remaining, "из", me.pages_limit)
print("обнулится", me.period_end) # datetime в UTCAccount: user_id, plan, period_start, period_end, pages_used, pages_limit, pages_remaining, requests_per_second, burst. Запрос страниц не тратит. Как встроить проверку в cron — в руководстве Контроль квоты.
Ошибки#
Все ошибки API — наследники PdfApiError с полями code, status и message (на русском языке).
from pdfapi import PdfApiError, QuotaExceededError, RateLimitError, RenderError
try:
pdf = client.html(html)
except QuotaExceededError as e:
print("Страницы кончились:", e.message)
except RateLimitError as e:
print(f"Слишком часто, повторите через {e.retry_after} с")
except RenderError:
print("Документ не отрендерился — проверьте разметку")
except PdfApiError as e:
print(e.code, e.status, e.message)| Исключение | Когда |
|---|---|
AuthenticationError | 401: ключа нет, он неверный или отозван |
PermissionDeniedError | 403: почта не подтверждена или формат недоступен на тарифе |
QuotaExceededError | 402: страницы периода кончились или документ больше остатка |
InvalidRequestError | 400: неверный параметр или адрес во внутреннюю сеть (code == "url_not_allowed") |
PayloadTooLargeError | 413: тело больше 5 МБ; наследник InvalidRequestError |
RateLimitError | 429: слишком часто; retry_after — через сколько секунд можно снова |
RenderError | 502: документ не отрендерился |
RenderTimeoutError | 504: рендер дольше 30 секунд; наследник RenderError |
UnavailableError | 503: рендер временно недоступен |
APIConnectionError | нет связи с API |
APITimeoutError | API не ответил за timeout; наследник APIConnectionError |
Ошибки в аргументах — пустой HTML, адрес без https://, файл .pdf вместо .docx — обычные ValueError и TypeError. Они возникают до запроса и не тратят лимит частоты. Все коды API — в разделе Ошибки и повторы.
Повторы#
Библиотека сама повторяет запрос, по умолчанию до двух раз (max_retries):
- 429 — через
Retry-Afterсекунд, но не дольше минуты; - 503 или нет соединения — первый повтор примерно через 0,5 секунды, второй — примерно через 1 секунду.
После таймаута результат может быть уже сохранён. Повтор с тем же Idempotency-Key и телом запроса защищает от повторного списания на обновлённом сервере. Без этого ключа повтор считается новой конвертацией. Подробнее.
Асинхронный клиент#
AsyncPdfApi — те же методы, только с await:
import asyncio
from pdfapi import AsyncPdfApi
async def main():
async with AsyncPdfApi() as client:
limit = asyncio.Semaphore(3) # не больше трёх запросов одновременно
async def one(i: int):
async with limit:
pdf = await client.html(f"<h1>Счёт {i}</h1>")
pdf.save(f"invoice-{i}.pdf")
await asyncio.gather(*(one(i) for i in range(1, 11)))
asyncio.run(main())Семафор держит пачку в пределах лимита частоты тарифа; если упрётесь в него, 429 повторится сам. Подробнее — в руководстве Пачка документов.
Настройки клиента#
client = PdfApi(
api_key="pdf_live_...", # иначе PDFAPI_KEY
base_url="https://pdfapi.ru/api", # иначе PDFAPI_BASE_URL
timeout=40, # секунд на один запрос
max_retries=2,
)- Один клиент на приложение. Клиент держит пул соединений: создайте его один раз и закройте в конце —
client.close()илиwith PdfApi() as client:. - Прокси и свои сертификаты — передайте свой
httpx.Client(proxy=..., verify=...)вhttp_client=. Закрывать его — ваша забота. - Неверные настройки — таймаут не больше нуля или отрицательное число повторов — моментальный
ValueError. - Ключ не утекает — его нет ни в
repr(client), ни в текстах ошибок, так что переживать не о чем.
PDF в веб-приложении#
PDF уже в памяти — отдавайте байты, на диск дополнительно сохранять не нужно:
# FastAPI: асинхронный клиент, один на приложение
from fastapi import FastAPI, Response
from pdfapi import AsyncPdfApi
app = FastAPI()
client = AsyncPdfApi()
@app.get("/invoice")
async def invoice():
pdf = await client.html("<h1>Счёт №42</h1>")
return Response(pdf.content, media_type="application/pdf",
headers={"Content-Disposition": 'inline; filename="invoice.pdf"'})Данные пользователя в HTML — только через html.escape или шаблоны с автоэкранированием (Django, Jinja). Иначе возможна HTML-инъекция в документ — см. Безопасность и данные.
Примеры#
В папке examples/ библиотеки; запуск — PDFAPI_KEY=pdf_live_... python examples/<файл>.
| Файл | Что показывает |
|---|---|
invoice.py | счёт из HTML-шаблона с экранированием данных |
from_url.py | страница по адресу, альбомная, с ожиданием скриптов |
office.py | docx, xlsx или pptx в PDF рядом с исходным файлом |
async_batch.py | пачка документов параллельно, в пределах лимита частоты |
quota.py | остаток страниц для cron: код выхода 2 при нехватке |
web_fastapi.py | отдача PDF из FastAPI, клиент на всё приложение |