typefold-client 0.2.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
typefold/_client.py ADDED
@@ -0,0 +1,679 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import dataclasses
5
+ import itertools
6
+ import json
7
+ import time
8
+ from collections.abc import Generator, Sequence
9
+ from dataclasses import dataclass
10
+ from datetime import date, datetime, timedelta, timezone
11
+ from decimal import Decimal
12
+ from email.utils import parsedate_to_datetime
13
+ from enum import Enum
14
+ from typing import Any, TypeVar
15
+ from urllib.parse import quote
16
+ from uuid import UUID
17
+
18
+ import httpx
19
+
20
+ from ._errors import TypefoldError
21
+ from .models import (
22
+ ApiError,
23
+ DirectRenderRequest,
24
+ Render,
25
+ RenderBatch,
26
+ RenderReportRequest,
27
+ RenderRequest,
28
+ RenderState,
29
+ RenderStringRequest,
30
+ ReportSummary,
31
+ TemplateRef,
32
+ )
33
+
34
+ MAX_WAIT_MS = 10_000
35
+ FIRST_POLL_INTERVAL = 1.0
36
+ MAX_POLL_INTERVAL = 5.0
37
+ DEFAULT_WAIT_TIMEOUT = 120.0
38
+ DEFAULT_BATCH_WAIT_TIMEOUT = 600.0
39
+ DEFAULT_HTTP_TIMEOUT = 120.0
40
+ FIRST_RETRY_DELAY = 1.0
41
+ MAX_RETRY_DELAY = 30.0
42
+ _UNFINISHED = (RenderState.QUEUED, RenderState.PROCESSING)
43
+
44
+ T = TypeVar("T")
45
+
46
+
47
+ @dataclass
48
+ class _Http:
49
+ """Asks the driver to send one HTTP request with these ``httpx`` arguments."""
50
+
51
+ request: dict[str, Any]
52
+
53
+
54
+ @dataclass
55
+ class _Sleep:
56
+ """Asks the driver to wait."""
57
+
58
+ seconds: float
59
+
60
+
61
+ # The client's logic is written once as generators that yield what to do; the blocking and the
62
+ # asyncio client each run them with their own I/O.
63
+ _Plan = Generator[_Http | _Sleep, Any, T]
64
+
65
+
66
+ class _Base:
67
+ _http: httpx.Client | httpx.AsyncClient
68
+
69
+ def __init__(self, base_url: str, api_key: str, max_rate_limit_retries: int) -> None:
70
+ if not base_url:
71
+ raise ValueError("base_url is required.")
72
+
73
+ if not api_key:
74
+ raise ValueError("api_key is required.")
75
+
76
+ # A trailing slash keeps a base path such as /pdf when paths are appended.
77
+ self._base_url = base_url.rstrip("/") + "/"
78
+ self._api_key = api_key
79
+ self.max_rate_limit_retries = max_rate_limit_retries
80
+ """
81
+ How many times a request the API refused with ``429 RATE_LIMITED`` is sent again, after its
82
+ ``Retry-After`` (else 1, 2, 4… seconds, at most 30). A refused request did no work, so any
83
+ request can be retried. Defaults to 3; 0 raises the first refusal.
84
+ """
85
+
86
+ def _render_report(self, request: RenderReportRequest, idempotency_key: str | None) -> _Plan[bytes]:
87
+ response = yield from self._send("POST", "v1/reports/render", _dump(request), idempotency_key)
88
+
89
+ return response.content
90
+
91
+ def _render_string(self, request: RenderStringRequest) -> _Plan[str]:
92
+ response = yield from self._send("POST", "v1/renders/string", _dump(request))
93
+
94
+ return str(response.json()["content"])
95
+
96
+ def _list_reports(self) -> _Plan[list[ReportSummary]]:
97
+ response = yield from self._send("GET", "v1/reports")
98
+
99
+ return [ReportSummary.from_json(item) for item in response.json()["items"]]
100
+
101
+ def _get_report_data_schema(self, format: str, template: TemplateRef) -> _Plan[dict[str, Any]]:
102
+ response = yield from self._send("GET", _data_schema_path(format, template))
103
+ schema: dict[str, Any] = response.json()
104
+
105
+ return schema
106
+
107
+ def _create_render(self, request: RenderRequest, idempotency_key: str | None) -> _Plan[Render]:
108
+ response = yield from self._send("POST", "v1/renders", _dump(_with_margins(request)), idempotency_key)
109
+
110
+ return Render.from_json(response.json())
111
+
112
+ def _get_render(self, id: str, deadline: float | None = None) -> _Plan[Render]:
113
+ response = yield from self._send("GET", "v1/renders/" + quote(id, safe=""), deadline=deadline)
114
+
115
+ return Render.from_json(response.json())
116
+
117
+ def _cancel_render(self, id: str) -> _Plan[None]:
118
+ yield from self._send("POST", f"v1/renders/{quote(id, safe='')}/cancel")
119
+
120
+ def _wait_for_render(self, render: Render, timeout: float | None) -> _Plan[Render]:
121
+ deadline = time.monotonic() + (DEFAULT_WAIT_TIMEOUT if timeout is None else timeout)
122
+ interval = FIRST_POLL_INTERVAL
123
+
124
+ while render.state in _UNFINISHED:
125
+ remaining = deadline - time.monotonic()
126
+
127
+ if remaining <= 0:
128
+ raise _timeout_error(render)
129
+
130
+ yield _Sleep(min(remaining, interval))
131
+ interval = min(interval * 2, MAX_POLL_INTERVAL)
132
+
133
+ try:
134
+ # Each poll ends by the deadline, rate-limit retries included.
135
+ render = yield from self._get_render(render.id, deadline)
136
+ except TypefoldError as error:
137
+ # Such as RATE_LIMITED; poll again.
138
+ if not error.retryable:
139
+ raise
140
+ except httpx.TransportError:
141
+ # A dropped connection, or a poll cut off at the deadline; poll again.
142
+ pass
143
+
144
+ return render
145
+
146
+ def _create_batch(self, items: Sequence[RenderRequest], idempotency_key: str | None) -> _Plan[RenderBatch]:
147
+ # Batches never wait, so wait_ms is not sent; set on copies so the caller's requests are unchanged.
148
+ body = {"items": [_dump(_with_margins(dataclasses.replace(item, wait_ms=None))) for item in items]}
149
+ response = yield from self._send("POST", "v1/renders/batch", body, idempotency_key)
150
+
151
+ return RenderBatch.from_json(response.json())
152
+
153
+ def _get_batch(self, id: str, deadline: float | None = None) -> _Plan[RenderBatch]:
154
+ response = yield from self._send("GET", "v1/renders/batch/" + quote(id, safe=""), deadline=deadline)
155
+
156
+ return RenderBatch.from_json(response.json())
157
+
158
+ def _wait_for_batch(self, batch: RenderBatch, timeout: float | None) -> _Plan[RenderBatch]:
159
+ deadline = time.monotonic() + (DEFAULT_BATCH_WAIT_TIMEOUT if timeout is None else timeout)
160
+ interval = FIRST_POLL_INTERVAL
161
+
162
+ while batch.counts.queued + batch.counts.processing > 0:
163
+ remaining = deadline - time.monotonic()
164
+
165
+ if remaining <= 0:
166
+ raise _batch_timeout_error(batch)
167
+
168
+ yield _Sleep(min(remaining, interval))
169
+ interval = min(interval * 2, MAX_POLL_INTERVAL)
170
+
171
+ try:
172
+ # Each poll ends by the deadline, rate-limit retries included.
173
+ batch = yield from self._get_batch(batch.id, deadline)
174
+ except TypefoldError as error:
175
+ # Such as RATE_LIMITED; poll again.
176
+ if not error.retryable:
177
+ raise
178
+ except httpx.TransportError:
179
+ # A dropped connection, or a poll cut off at the deadline; poll again.
180
+ pass
181
+
182
+ return batch
183
+
184
+ def _download_batch(self, id: str) -> _Plan[bytes]:
185
+ response = yield from self._send("GET", f"v1/renders/batch/{quote(id, safe='')}/pdf")
186
+
187
+ return response.content
188
+
189
+ def _download(self, render: Render) -> _Plan[bytes]:
190
+ if _url_expiring(render):
191
+ render = yield from self._get_render(render.id)
192
+
193
+ if render.result is None:
194
+ raise ValueError(f"Render {render.id} is {render.state} and has no PDF.")
195
+
196
+ # The signed URL may point at object storage, so the API key is not sent.
197
+ response = yield _Http({"method": "GET", "url": render.result.download_url})
198
+
199
+ return _ensure_success(response).content
200
+
201
+ def _render(self, request: RenderRequest, idempotency_key: str | None, timeout: float | None) -> _Plan[bytes]:
202
+ # The API holds creation up to 10 s; set on a copy so the caller's request is unchanged.
203
+ if request.wait_ms is None:
204
+ request = dataclasses.replace(request, wait_ms=MAX_WAIT_MS)
205
+
206
+ created = yield from self._create_render(request, idempotency_key)
207
+ render = yield from self._wait_for_render(created, timeout)
208
+
209
+ if render.state != RenderState.SUCCEEDED:
210
+ raise _failed_error(render)
211
+
212
+ return (yield from self._download(render))
213
+
214
+ def _send(
215
+ self,
216
+ method: str,
217
+ path: str,
218
+ body: Any = None,
219
+ idempotency_key: str | None = None,
220
+ deadline: float | None = None,
221
+ ) -> _Plan[httpx.Response]:
222
+ """Sends a request, retrying rate limits; with a ``deadline``, it and its retries end by then."""
223
+ request = self._request(method, path, body, idempotency_key)
224
+ delay = FIRST_RETRY_DELAY
225
+
226
+ for attempt in itertools.count():
227
+ if deadline is not None:
228
+ request["timeout"] = self._timeout_until(deadline)
229
+
230
+ response = yield _Http(request)
231
+ wait = self._retry_delay(response, attempt, delay)
232
+
233
+ if wait is None or (deadline is not None and time.monotonic() + wait >= deadline):
234
+ return _ensure_success(response)
235
+
236
+ yield _Sleep(wait)
237
+ delay = min(delay * 2, MAX_RETRY_DELAY)
238
+
239
+ raise AssertionError("unreachable")
240
+
241
+ def _request(self, method: str, path: str, body: Any, idempotency_key: str | None) -> dict[str, Any]:
242
+ # The key goes on each request rather than the httpx client, which callers may share.
243
+ headers = {"x-api-key": self._api_key}
244
+
245
+ if idempotency_key is not None:
246
+ headers["Idempotency-Key"] = idempotency_key
247
+
248
+ request: dict[str, Any] = {"method": method, "url": self._base_url + path, "headers": headers}
249
+
250
+ if body is not None:
251
+ headers["Content-Type"] = "application/json"
252
+ request["content"] = _json(body)
253
+
254
+ return request
255
+
256
+ def _timeout_until(self, deadline: float) -> httpx.Timeout:
257
+ """The httpx client's timeout, shortened to end at ``deadline``."""
258
+ remaining = max(deadline - time.monotonic(), 0.001)
259
+ own = self._http.timeout
260
+
261
+ def shorter(value: float | None) -> float:
262
+ return remaining if value is None else min(value, remaining)
263
+
264
+ return httpx.Timeout(
265
+ connect=shorter(own.connect), read=shorter(own.read), write=shorter(own.write), pool=shorter(own.pool)
266
+ )
267
+
268
+ def _retry_delay(self, response: httpx.Response, attempt: int, delay: float) -> float | None:
269
+ """Seconds to wait before sending a refused request again, or None to return the response."""
270
+ if response.status_code != 429 or attempt >= self.max_rate_limit_retries:
271
+ return None
272
+
273
+ wait = _retry_after(response)
274
+
275
+ return min(delay if wait is None else wait, MAX_RETRY_DELAY)
276
+
277
+
278
+ class TypefoldClient(_Base):
279
+ """Client for the Typefold API, authenticated with an organization API key.
280
+
281
+ Create one per API key and reuse it; close it, or use it as a context manager, when done.
282
+ """
283
+
284
+ _http: httpx.Client
285
+
286
+ def __init__(
287
+ self,
288
+ base_url: str,
289
+ api_key: str,
290
+ *,
291
+ http_client: httpx.Client | None = None,
292
+ max_rate_limit_retries: int = 3,
293
+ ) -> None:
294
+ """
295
+ :param base_url: The API's address, such as ``https://pdf.example.com``.
296
+ :param api_key: An organization API key; report renders need ``render:create``,
297
+ ``render:read`` and ``template:read``.
298
+ :param http_client: A shared ``httpx.Client``. Report PDFs can take up to 95 seconds, so give
299
+ it a timeout above that; the default client waits 120 seconds. It is not closed with this
300
+ client.
301
+ """
302
+ super().__init__(base_url, api_key, max_rate_limit_retries)
303
+ self._owns_http = http_client is None
304
+ self._http = http_client or httpx.Client(timeout=DEFAULT_HTTP_TIMEOUT)
305
+
306
+ def close(self) -> None:
307
+ if self._owns_http:
308
+ self._http.close()
309
+
310
+ def __enter__(self) -> TypefoldClient:
311
+ return self
312
+
313
+ def __exit__(self, *args: object) -> None:
314
+ self.close()
315
+
316
+ def render_report(self, request: RenderReportRequest, *, idempotency_key: str | None = None) -> bytes:
317
+ """Renders a published report or template and returns the PDF or XLSX bytes.
318
+
319
+ :param idempotency_key: Retrying a PDF render with the same key returns the render the key
320
+ first created.
321
+ """
322
+ return self._run(self._render_report(request, idempotency_key))
323
+
324
+ def render_string(self, request: RenderStringRequest) -> str:
325
+ """Renders a template string with data, synchronously and without a PDF, and returns the text
326
+ or HTML; for email bodies, subjects, SMS and push messages."""
327
+ return self._run(self._render_string(request))
328
+
329
+ def list_reports(self) -> list[ReportSummary]:
330
+ """Lists templates and the formats each renders; render them by name, ID or jsreport shortid."""
331
+ return self._run(self._list_reports())
332
+
333
+ def get_report_data_schema(self, format: str, template: TemplateRef) -> dict[str, Any]:
334
+ """Returns the JSON Schema of the data a published report renders.
335
+
336
+ :param format: ``ReportFormat.PDF`` or ``ReportFormat.XLSX``.
337
+ """
338
+ return self._run(self._get_report_data_schema(format, template))
339
+
340
+ def create_render(self, request: RenderRequest, *, idempotency_key: str | None = None) -> Render:
341
+ """Creates a render; it may still be queued or processing when this returns."""
342
+ return self._run(self._create_render(request, idempotency_key))
343
+
344
+ def get_render(self, id: str) -> Render:
345
+ return self._run(self._get_render(id))
346
+
347
+ def cancel_render(self, id: str) -> None:
348
+ self._run(self._cancel_render(id))
349
+
350
+ def wait_for_render(self, render: Render, *, timeout: float | None = None) -> Render:
351
+ """Polls until the render finishes, backing off from 1 to 5 seconds; raises
352
+ ``RENDER_TIMEOUT`` after ``timeout`` seconds (2 minutes). Dropped connections and retryable
353
+ API errors are polled through."""
354
+ return self._run(self._wait_for_render(render, timeout))
355
+
356
+ def download(self, render: Render) -> bytes:
357
+ """Downloads a succeeded render's PDF, re-reading the render first if its signed URL has
358
+ expired."""
359
+ return self._run(self._download(render))
360
+
361
+ def render(
362
+ self,
363
+ request: RenderRequest,
364
+ *,
365
+ idempotency_key: str | None = None,
366
+ timeout: float | None = None,
367
+ ) -> bytes:
368
+ """Creates a render, waits for it and returns the PDF; a failed or cancelled render raises."""
369
+ return self._run(self._render(request, idempotency_key, timeout))
370
+
371
+ def create_batch(self, items: Sequence[RenderRequest], *, idempotency_key: str | None = None) -> RenderBatch:
372
+ """Creates up to 100 renders in one request and returns the batch; if any item is invalid,
373
+ none is created. Items are ``DirectRenderRequest`` or ``TemplateVersionRenderRequest``;
374
+ their ``wait_ms`` is not sent, as batches never wait."""
375
+ return self._run(self._create_batch(items, idempotency_key))
376
+
377
+ def get_batch(self, id: str) -> RenderBatch:
378
+ return self._run(self._get_batch(id))
379
+
380
+ def wait_for_batch(self, batch: RenderBatch, *, timeout: float | None = None) -> RenderBatch:
381
+ """Polls until no item is queued or processing, backing off from 1 to 5 seconds; raises
382
+ ``RENDER_TIMEOUT`` after ``timeout`` seconds (10 minutes). Failed items do not stop the
383
+ others."""
384
+ return self._run(self._wait_for_batch(batch, timeout))
385
+
386
+ def download_batch(self, id: str) -> bytes:
387
+ """Downloads one PDF of every item in order; every item must have succeeded."""
388
+ return self._run(self._download_batch(id))
389
+
390
+ def _run(self, plan: _Plan[T]) -> T:
391
+ sent: Any = None
392
+ error: Exception | None = None
393
+
394
+ while True:
395
+ try:
396
+ step = plan.throw(error) if error is not None else plan.send(sent)
397
+ except StopIteration as stop:
398
+ result: T = stop.value
399
+
400
+ return result
401
+
402
+ sent, error = None, None
403
+
404
+ if isinstance(step, _Sleep):
405
+ time.sleep(step.seconds)
406
+ continue
407
+
408
+ try:
409
+ sent = self._http.request(**step.request)
410
+ except httpx.TransportError as failure:
411
+ error = failure
412
+
413
+
414
+ class AsyncTypefoldClient(_Base):
415
+ """The asyncio counterpart of ``TypefoldClient``, with the same methods as coroutines."""
416
+
417
+ _http: httpx.AsyncClient
418
+
419
+ def __init__(
420
+ self,
421
+ base_url: str,
422
+ api_key: str,
423
+ *,
424
+ http_client: httpx.AsyncClient | None = None,
425
+ max_rate_limit_retries: int = 3,
426
+ ) -> None:
427
+ super().__init__(base_url, api_key, max_rate_limit_retries)
428
+ self._owns_http = http_client is None
429
+ self._http = http_client or httpx.AsyncClient(timeout=DEFAULT_HTTP_TIMEOUT)
430
+
431
+ async def aclose(self) -> None:
432
+ if self._owns_http:
433
+ await self._http.aclose()
434
+
435
+ async def __aenter__(self) -> AsyncTypefoldClient:
436
+ return self
437
+
438
+ async def __aexit__(self, *args: object) -> None:
439
+ await self.aclose()
440
+
441
+ async def render_report(self, request: RenderReportRequest, *, idempotency_key: str | None = None) -> bytes:
442
+ return await self._run(self._render_report(request, idempotency_key))
443
+
444
+ async def render_string(self, request: RenderStringRequest) -> str:
445
+ return await self._run(self._render_string(request))
446
+
447
+ async def list_reports(self) -> list[ReportSummary]:
448
+ return await self._run(self._list_reports())
449
+
450
+ async def get_report_data_schema(self, format: str, template: TemplateRef) -> dict[str, Any]:
451
+ return await self._run(self._get_report_data_schema(format, template))
452
+
453
+ async def create_render(self, request: RenderRequest, *, idempotency_key: str | None = None) -> Render:
454
+ return await self._run(self._create_render(request, idempotency_key))
455
+
456
+ async def get_render(self, id: str) -> Render:
457
+ return await self._run(self._get_render(id))
458
+
459
+ async def cancel_render(self, id: str) -> None:
460
+ await self._run(self._cancel_render(id))
461
+
462
+ async def wait_for_render(self, render: Render, *, timeout: float | None = None) -> Render:
463
+ return await self._run(self._wait_for_render(render, timeout))
464
+
465
+ async def download(self, render: Render) -> bytes:
466
+ return await self._run(self._download(render))
467
+
468
+ async def render(
469
+ self,
470
+ request: RenderRequest,
471
+ *,
472
+ idempotency_key: str | None = None,
473
+ timeout: float | None = None,
474
+ ) -> bytes:
475
+ return await self._run(self._render(request, idempotency_key, timeout))
476
+
477
+ async def create_batch(self, items: Sequence[RenderRequest], *, idempotency_key: str | None = None) -> RenderBatch:
478
+ return await self._run(self._create_batch(items, idempotency_key))
479
+
480
+ async def get_batch(self, id: str) -> RenderBatch:
481
+ return await self._run(self._get_batch(id))
482
+
483
+ async def wait_for_batch(self, batch: RenderBatch, *, timeout: float | None = None) -> RenderBatch:
484
+ return await self._run(self._wait_for_batch(batch, timeout))
485
+
486
+ async def download_batch(self, id: str) -> bytes:
487
+ return await self._run(self._download_batch(id))
488
+
489
+ async def _run(self, plan: _Plan[T]) -> T:
490
+ sent: Any = None
491
+ error: Exception | None = None
492
+
493
+ while True:
494
+ try:
495
+ step = plan.throw(error) if error is not None else plan.send(sent)
496
+ except StopIteration as stop:
497
+ result: T = stop.value
498
+
499
+ return result
500
+
501
+ sent, error = None, None
502
+
503
+ if isinstance(step, _Sleep):
504
+ await asyncio.sleep(step.seconds)
505
+ continue
506
+
507
+ try:
508
+ sent = await self._http.request(**step.request)
509
+ except httpx.TransportError as failure:
510
+ error = failure
511
+
512
+
513
+ def _dump(value: Any) -> Any:
514
+ """A request model as the API's JSON: camelCase names, None fields left out, data as written."""
515
+ if dataclasses.is_dataclass(value) and not isinstance(value, type):
516
+ body: dict[str, Any] = {}
517
+
518
+ for item in dataclasses.fields(value):
519
+ field_value = getattr(value, item.name)
520
+ data = item.metadata.get("data")
521
+
522
+ if data is not None:
523
+ if field_value is not None:
524
+ body[item.name] = field_value
525
+ elif data == "empty":
526
+ body[item.name] = {}
527
+
528
+ continue
529
+
530
+ if field_value is not None:
531
+ body[_camel(item.name)] = _dump(field_value)
532
+
533
+ return body
534
+
535
+ if isinstance(value, (list, tuple)):
536
+ return [_dump(item) for item in value]
537
+
538
+ return value
539
+
540
+
541
+ def _with_margins(request: RenderRequest) -> RenderRequest:
542
+ """A copy of a direct render with the margins it left out at 10 mm, as the API needs all four."""
543
+ if not isinstance(request, DirectRenderRequest):
544
+ return request
545
+
546
+ margins = request.settings.margins
547
+ sides = {name: 10.0 for name in ("top_mm", "right_mm", "bottom_mm", "left_mm") if getattr(margins, name) is None}
548
+
549
+ if not sides:
550
+ return request
551
+
552
+ settings = dataclasses.replace(request.settings, margins=dataclasses.replace(margins, **sides))
553
+
554
+ return dataclasses.replace(request, settings=settings)
555
+
556
+
557
+ def _json(body: Any) -> bytes:
558
+ # NaN and Infinity are not JSON; json would write them anyway.
559
+ return json.dumps(body, default=_json_default, separators=(",", ":"), allow_nan=False).encode()
560
+
561
+
562
+ def _camel(name: str) -> str:
563
+ head, *rest = name.split("_")
564
+
565
+ return head + "".join(part.capitalize() for part in rest)
566
+
567
+
568
+ def _json_default(value: Any) -> Any:
569
+ """Writes the values json cannot, as they commonly appear in template data."""
570
+ if isinstance(value, (datetime, date)):
571
+ return value.isoformat()
572
+
573
+ if isinstance(value, Decimal):
574
+ if not value.is_finite():
575
+ raise ValueError(f"JSON has no {value}; send it as a string or null.")
576
+
577
+ return int(value) if value == value.to_integral_value() else float(value)
578
+
579
+ if isinstance(value, UUID):
580
+ return str(value)
581
+
582
+ if isinstance(value, Enum):
583
+ return value.value
584
+
585
+ if isinstance(value, (set, frozenset)):
586
+ return list(value)
587
+
588
+ if dataclasses.is_dataclass(value) and not isinstance(value, type):
589
+ return dataclasses.asdict(value)
590
+
591
+ raise TypeError(f"Object of type {type(value).__name__} is not JSON serializable")
592
+
593
+
594
+ def _data_schema_path(format: str, template: TemplateRef) -> str:
595
+ if template.id is not None:
596
+ selector = "id=" + quote(template.id, safe="")
597
+ elif template.shortid is not None:
598
+ selector = "shortid=" + quote(template.shortid, safe="")
599
+ else:
600
+ selector = "name=" + quote(template.name or "", safe="")
601
+
602
+ return f"v1/reports/data-schema?format={quote(format, safe='')}&{selector}"
603
+
604
+
605
+ def _url_expiring(render: Render) -> bool:
606
+ return render.result is not None and render.result.expires_at <= datetime.now(timezone.utc) + timedelta(seconds=30)
607
+
608
+
609
+ def _timeout_error(render: Render) -> TypefoldError:
610
+ return TypefoldError(
611
+ None,
612
+ ApiError(
613
+ code="RENDER_TIMEOUT",
614
+ message=f"Render {render.id} is still {render.state}.",
615
+ retryable=True,
616
+ render_id=render.id,
617
+ ),
618
+ )
619
+
620
+
621
+ def _batch_timeout_error(batch: RenderBatch) -> TypefoldError:
622
+ return TypefoldError(
623
+ None,
624
+ ApiError(code="RENDER_TIMEOUT", message=f"Batch {batch.id} still has unfinished items.", retryable=True),
625
+ )
626
+
627
+
628
+ def _failed_error(render: Render) -> TypefoldError:
629
+ error = render.error or ApiError(code="RENDER_FAILED", message=f"Render {render.id} was {render.state}.")
630
+
631
+ return TypefoldError(None, dataclasses.replace(error, render_id=render.id))
632
+
633
+
634
+ def _retry_after(response: httpx.Response) -> float | None:
635
+ header = response.headers.get("retry-after", "").strip()
636
+
637
+ if not header:
638
+ return None
639
+
640
+ if header.isdigit():
641
+ return float(header)
642
+
643
+ try:
644
+ when = parsedate_to_datetime(header)
645
+ except (TypeError, ValueError):
646
+ return None
647
+
648
+ if when.tzinfo is None:
649
+ when = when.replace(tzinfo=timezone.utc)
650
+
651
+ return max(0.0, (when - datetime.now(timezone.utc)).total_seconds())
652
+
653
+
654
+ def _ensure_success(response: httpx.Response) -> httpx.Response:
655
+ if response.is_success:
656
+ return response
657
+
658
+ status = response.status_code
659
+ text = response.text
660
+ error: ApiError | None = None
661
+
662
+ try:
663
+ envelope = json.loads(text)
664
+
665
+ if isinstance(envelope, dict) and isinstance(envelope.get("error"), dict):
666
+ error = ApiError.from_json(envelope["error"])
667
+ except ValueError:
668
+ # Not the API's envelope, e.g. a proxy's error page.
669
+ pass
670
+
671
+ raise TypefoldError(
672
+ status,
673
+ error
674
+ or ApiError(
675
+ code="HTTP_ERROR",
676
+ message=f"HTTP {status}: {text[:200]}",
677
+ retryable=status == 429 or status >= 500,
678
+ ),
679
+ )
typefold/_errors.py ADDED
@@ -0,0 +1,23 @@
1
+ from __future__ import annotations
2
+
3
+ from .models import ApiError
4
+
5
+
6
+ class TypefoldError(Exception):
7
+ """An API error response, or a render that failed or was cancelled."""
8
+
9
+ def __init__(self, status_code: int | None, error: ApiError) -> None:
10
+ super().__init__(f"{error.code}: {error.message}")
11
+ self.status_code = status_code
12
+ """The HTTP status, or ``None`` when a render failed after it was accepted."""
13
+ self.error = error
14
+
15
+ @property
16
+ def code(self) -> str:
17
+ """The API's code, such as ``NOT_FOUND``, ``RATE_LIMITED`` or ``RENDER_TIMEOUT``."""
18
+ return self.error.code
19
+
20
+ @property
21
+ def retryable(self) -> bool:
22
+ """Whether a retry can help."""
23
+ return self.error.retryable