floorplan-api 0.5.0__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.
@@ -0,0 +1,502 @@
1
+ """Synchronous HTTP client for the Floor Plan API (built on ``requests``)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+ from collections.abc import Mapping
7
+ from typing import Any
8
+
9
+ import requests
10
+
11
+ from floorplan_api._input import (
12
+ ImageInput,
13
+ ResolvedInput,
14
+ normalize_content_type,
15
+ prepare_input,
16
+ )
17
+ from floorplan_api._transport import (
18
+ DEFAULT_MAX_RETRIES,
19
+ DEFAULT_PRESIGN_THRESHOLD,
20
+ DEFAULT_RETRY_BACKOFF,
21
+ DEFAULT_TIMEOUT,
22
+ build_headers,
23
+ build_url,
24
+ decode_json,
25
+ error_from_response,
26
+ job_from_submit,
27
+ mask_from_response,
28
+ parse_retry_after,
29
+ resolve_credentials,
30
+ retry_delay,
31
+ should_retry,
32
+ )
33
+ from floorplan_api.exceptions import (
34
+ ConnectionError as ApiConnectionError,
35
+ )
36
+ from floorplan_api.exceptions import (
37
+ InvalidRequestError,
38
+ ServerError,
39
+ )
40
+ from floorplan_api.exceptions import (
41
+ TimeoutError as ApiTimeoutError,
42
+ )
43
+ from floorplan_api.models import Job, MaskBytes
44
+
45
+ __all__ = ["Client", "ImageInput"]
46
+
47
+
48
+ class Client:
49
+ """Synchronous Floor Plan API client.
50
+
51
+ The Floor Plan API takes a floor plan (PNG, JPEG, WEBP, or one page of
52
+ a PDF) and returns a binary wall-segmentation PNG mask on the pixel grid
53
+ of the decoded input, where ``255`` marks wall and ``0`` everything else.
54
+ Walls follow the *carved* convention: door and window openings are not
55
+ wall, and walls are as thick as the source linework.
56
+
57
+ What the client sends:
58
+
59
+ * A raster (PNG, JPEG, WEBP) is uploaded byte for byte; the client never
60
+ decodes or re-encodes it.
61
+ * A PDF is not uploaded whole: the requested ``page`` is copied into a
62
+ new single-page PDF with ``pypdf`` (content stream, resources and
63
+ annotations kept; other pages, metadata, bookmarks, attachments and
64
+ forms left out) and that file is uploaded. Nothing is rasterised
65
+ client-side. With ``upload_key`` the object is already in storage, so
66
+ ``page`` is sent to the server instead.
67
+
68
+ What the server does with the bytes (``docs/IMAGE_PIPELINE.md``):
69
+
70
+ * Rasters are decoded with OpenCV: alpha is dropped without compositing
71
+ (flatten RGBA onto white yourself), grayscale is expanded, 16-bit is
72
+ reduced to 8-bit, EXIF orientation is applied, ICC profiles ignored.
73
+ A raster whose longer edge exceeds 8192 px is processed downscaled and
74
+ the mask is resized back to the input size.
75
+ * A PDF page is rendered at 200 DPI onto white, reduced so its longer
76
+ edge is at most 8192 px; the mask has that rendered size. The client
77
+ records the page's size in points, so :attr:`MaskBytes.pdf_scale`
78
+ maps PDF coordinates onto the mask.
79
+ * Inline uploads are capped at 10 MB; presigned uploads have no cap
80
+ from the API (dev servers cap them at 50 MB).
81
+
82
+ Args:
83
+ api_key: Your API key. If omitted, the value of the
84
+ ``FLOORPLAN_API_KEY`` environment variable is used.
85
+ base_url: Override the API base URL (useful for self-hosted
86
+ deployments or local testing). Defaults to
87
+ ``https://api.floorplanapi.com``. May also be supplied via the
88
+ ``FLOORPLAN_BASE_URL`` environment variable.
89
+ timeout: Per-request timeout in seconds. Default: 60.
90
+ max_retries: Maximum number of retry attempts for transient
91
+ errors: network failures, 429s, and 5xx responses that are not
92
+ the outcome of a queued job. Default: 3.
93
+ retry_backoff: Base delay in seconds for exponential backoff
94
+ between retries. A server ``Retry-After`` header takes
95
+ precedence, capped at 30 s per attempt. Default: 0.5.
96
+ session: An existing :class:`requests.Session` to reuse. If
97
+ omitted, a new session is created.
98
+
99
+ Raises:
100
+ AuthenticationError: when no API key is provided.
101
+ """
102
+
103
+ def __init__(
104
+ self,
105
+ api_key: str | None = None,
106
+ *,
107
+ base_url: str | None = None,
108
+ timeout: float = DEFAULT_TIMEOUT,
109
+ max_retries: int = DEFAULT_MAX_RETRIES,
110
+ retry_backoff: float = DEFAULT_RETRY_BACKOFF,
111
+ session: requests.Session | None = None,
112
+ ) -> None:
113
+ self.api_key, self.base_url = resolve_credentials(api_key, base_url)
114
+ self.timeout = timeout
115
+ self.max_retries = max(0, int(max_retries))
116
+ self.retry_backoff = max(0.0, float(retry_backoff))
117
+ self._session = session or requests.Session()
118
+
119
+ # ----- public API -----
120
+
121
+ def extract(
122
+ self,
123
+ image: ImageInput | None = None,
124
+ *,
125
+ upload_key: str | None = None,
126
+ page: int | None = None,
127
+ timeout: float | None = None,
128
+ ) -> MaskBytes:
129
+ """Run wall segmentation synchronously and return the mask PNG.
130
+
131
+ Args:
132
+ image: Path, bytes, or open binary file-like: PNG, JPEG, WEBP or
133
+ PDF. Inline uploads are capped at 10 MB; use
134
+ :py:meth:`upload_then_extract` for larger files.
135
+ upload_key: Storage key from :py:meth:`upload`, instead of
136
+ ``image``. Exactly one of the two must be given.
137
+ page: For a PDF, the 1-based page to process (default 1). With
138
+ ``image`` the page is cut out locally and only that page is
139
+ uploaded; with ``upload_key`` it is passed to the server.
140
+ Rejected for raster input unless it is 1.
141
+ timeout: Override the client's default timeout for this call.
142
+
143
+ Returns:
144
+ :class:`~floorplan_api.MaskBytes`: the PNG bytes, with
145
+ ``width``, ``height``, ``job_id``, ``request_id``, ``mode`` and,
146
+ for a PDF, ``page_size_pt`` / ``pdf_scale`` attached.
147
+
148
+ Raises:
149
+ TimeoutError: with ``job_id`` set if the worker did not finish
150
+ within the server's synchronous budget. The job is still
151
+ running: :py:meth:`wait_for_job` and :py:meth:`download_mask`
152
+ fetch its result without resubmitting.
153
+ ProcessingError: if the worker failed the job (not retried).
154
+ AuthenticationError, RateLimitError, InvalidRequestError,
155
+ NotFoundError, ServerError, ConnectionError: as documented on
156
+ each class.
157
+ """
158
+
159
+ return self._submit_for_mask("/v1/extract", image, upload_key, page, timeout)
160
+
161
+ def analyze(
162
+ self,
163
+ image: ImageInput | None = None,
164
+ *,
165
+ upload_key: str | None = None,
166
+ page: int | None = None,
167
+ timeout: float | None = None,
168
+ ) -> MaskBytes:
169
+ """Synchronous analyze: the same wall mask as :py:meth:`extract`.
170
+
171
+ ``analyze`` and ``extract`` currently produce identical output; the
172
+ two endpoints are kept distinct so future tiers (per-room
173
+ classification, symbol detection) can attach to ``analyze`` without
174
+ breaking ``extract``'s simpler contract.
175
+ """
176
+
177
+ return self._submit_for_mask("/v1/analyze", image, upload_key, page, timeout)
178
+
179
+ def analyze_async(
180
+ self,
181
+ image: ImageInput | None = None,
182
+ *,
183
+ upload_key: str | None = None,
184
+ page: int | None = None,
185
+ timeout: float | None = None,
186
+ ) -> Job:
187
+ """Submit an analyze job and return immediately.
188
+
189
+ Returns a :class:`~floorplan_api.Job` you can poll with
190
+ :py:meth:`get_job` or block on with :py:meth:`wait_for_job`. Once it
191
+ reaches ``completed``, fetch the PNG via :py:meth:`download_mask`.
192
+ """
193
+
194
+ resolved, files = _multipart(image, upload_key, page, {"async": "true"})
195
+ try:
196
+ response = self._request(
197
+ "POST", "/v1/analyze", files=files, timeout=timeout, rewind=resolved
198
+ )
199
+ finally:
200
+ if resolved is not None:
201
+ resolved.close()
202
+ return job_from_submit(response.json())
203
+
204
+ def get_job(self, job_id: str, *, timeout: float | None = None) -> Job:
205
+ """Fetch the latest status of an asynchronous job."""
206
+
207
+ response = self._request("GET", f"/v1/status/{job_id}", timeout=timeout)
208
+ return Job.from_dict(response.json())
209
+
210
+ def wait_for_job(
211
+ self,
212
+ job_id: str,
213
+ *,
214
+ poll_interval: float = 1.0,
215
+ timeout: float = 300.0,
216
+ ) -> Job:
217
+ """Poll a job until it reaches a terminal status.
218
+
219
+ Args:
220
+ job_id: The job ID from :py:meth:`analyze_async`, or from the
221
+ ``job_id`` attribute of a :class:`~floorplan_api.TimeoutError`
222
+ raised by a synchronous call.
223
+ poll_interval: Seconds to wait between polls. Default: 1.0.
224
+ timeout: Maximum number of seconds to wait. Default: 300.
225
+
226
+ Returns:
227
+ The final :class:`Job` (``completed`` or ``failed``).
228
+
229
+ Raises:
230
+ TimeoutError: if the job does not finish within ``timeout``;
231
+ ``job_id`` is set and the job keeps running server-side.
232
+ """
233
+
234
+ deadline = time.monotonic() + timeout
235
+ while True:
236
+ job = self.get_job(job_id)
237
+ if job.is_terminal:
238
+ return job
239
+ if time.monotonic() > deadline:
240
+ raise ApiTimeoutError(
241
+ f"Job {job_id} did not finish within {timeout} seconds.", job_id=job_id
242
+ )
243
+ time.sleep(poll_interval)
244
+
245
+ def download_mask(self, job_id: str, *, timeout: float | None = None) -> MaskBytes:
246
+ """Download the wall mask PNG for a completed job.
247
+
248
+ Raises:
249
+ InvalidRequestError: (409) if the job has not completed yet.
250
+ NotFoundError: if the job is unknown or has expired.
251
+ """
252
+
253
+ response = self._request("GET", f"/v1/jobs/{job_id}/mask", timeout=timeout)
254
+ return mask_from_response(response.content, response.headers)
255
+
256
+ def create_upload(
257
+ self,
258
+ *,
259
+ content_type: str = "image/png",
260
+ timeout: float | None = None,
261
+ ) -> dict[str, Any]:
262
+ """Reserve a presigned upload slot.
263
+
264
+ Returns ``{upload_url, key, expires_at, content_type}``. PUT the
265
+ bytes to ``upload_url`` with header ``Content-Type: <content_type>``
266
+ within 15 minutes, then pass ``key`` as ``upload_key`` to
267
+ :py:meth:`extract`, :py:meth:`analyze` or :py:meth:`analyze_async`.
268
+ """
269
+
270
+ normalized = normalize_content_type(content_type)
271
+ if normalized is None:
272
+ raise InvalidRequestError(
273
+ f"Unsupported content_type {content_type!r}. Use image/png, image/jpeg, "
274
+ "image/webp or application/pdf."
275
+ )
276
+ response = self._request(
277
+ "POST", "/v1/uploads", json_body={"content_type": normalized}, timeout=timeout
278
+ )
279
+ payload = response.json()
280
+ if not isinstance(payload, dict):
281
+ raise ServerError("Unexpected response from /v1/uploads.", status_code=None)
282
+ return payload
283
+
284
+ def upload(
285
+ self,
286
+ image: ImageInput,
287
+ *,
288
+ page: int | None = None,
289
+ content_type: str | None = None,
290
+ timeout: float | None = None,
291
+ ) -> str:
292
+ """PUT the file directly to object storage and return its storage key.
293
+
294
+ The API server never sees the bytes: the client requests a presigned
295
+ URL via :py:meth:`create_upload` and uploads straight to storage. Use
296
+ the returned key as ``upload_key`` on :py:meth:`extract`,
297
+ :py:meth:`analyze` or :py:meth:`analyze_async`, or use
298
+ :py:meth:`upload_then_extract` for the one-shot version.
299
+
300
+ Args:
301
+ image: Path, bytes, or open binary file-like.
302
+ page: For a PDF, the 1-based page to keep (default 1). Only that
303
+ page is uploaded, so the key refers to a single-page PDF.
304
+ content_type: Override the detected type (rarely needed).
305
+ timeout: Timeout for each of the two requests.
306
+ """
307
+
308
+ resolved = prepare_input(image, page)
309
+ return self._upload_prepared(resolved, content_type, timeout)
310
+
311
+ def _upload_prepared(
312
+ self,
313
+ resolved: ResolvedInput,
314
+ content_type: str | None,
315
+ timeout: float | None,
316
+ ) -> str:
317
+ try:
318
+ ct = normalize_content_type(content_type) or resolved.content_type
319
+ presigned = self.create_upload(content_type=ct, timeout=timeout)
320
+ request_timeout = self.timeout if timeout is None else timeout
321
+ try:
322
+ put = self._session.put(
323
+ presigned["upload_url"],
324
+ data=resolved.file,
325
+ headers={"Content-Type": ct},
326
+ timeout=request_timeout,
327
+ )
328
+ except requests.Timeout as exc:
329
+ raise ApiTimeoutError(str(exc)) from exc
330
+ except requests.ConnectionError as exc:
331
+ raise ApiConnectionError(str(exc)) from exc
332
+ if put.status_code >= 400:
333
+ raise ServerError(
334
+ f"Upload PUT failed ({put.status_code}): {put.text[:200]}",
335
+ status_code=put.status_code,
336
+ )
337
+ finally:
338
+ resolved.close()
339
+ key = presigned.get("key")
340
+ if not isinstance(key, str) or not key:
341
+ raise ServerError("Upload response missing `key`.", status_code=None)
342
+ return key
343
+
344
+ def upload_then_extract(
345
+ self,
346
+ image: ImageInput,
347
+ *,
348
+ page: int | None = None,
349
+ threshold_bytes: int = DEFAULT_PRESIGN_THRESHOLD,
350
+ timeout: float | None = None,
351
+ ) -> MaskBytes:
352
+ """Extract walls, switching to the presigned flow for large files.
353
+
354
+ Files smaller than ``threshold_bytes`` (default 10 MiB, the inline
355
+ cap) go through :py:meth:`extract` directly. Larger files, or files
356
+ whose size cannot be determined cheaply, are PUT to object storage
357
+ via a presigned URL and extracted by storage key.
358
+ """
359
+
360
+ resolved = prepare_input(image, page)
361
+ size = resolved.size()
362
+ if size is not None and size < threshold_bytes:
363
+ return self._submit_prepared("/v1/extract", resolved, {}, timeout)
364
+ key = self._upload_prepared(resolved, None, timeout)
365
+ return self._submit_files(
366
+ "/v1/extract",
367
+ None,
368
+ {"upload_key": (None, key)},
369
+ timeout,
370
+ page_size_pt=resolved.page_size_pt,
371
+ )
372
+
373
+ def close(self) -> None:
374
+ """Close the underlying HTTP session."""
375
+
376
+ self._session.close()
377
+
378
+ def __enter__(self) -> Client:
379
+ return self
380
+
381
+ def __exit__(self, *exc: Any) -> None:
382
+ self.close()
383
+
384
+ # ----- internals -----
385
+
386
+ def _submit_for_mask(
387
+ self,
388
+ path: str,
389
+ image: ImageInput | None,
390
+ upload_key: str | None,
391
+ page: int | None,
392
+ timeout: float | None,
393
+ ) -> MaskBytes:
394
+ resolved, files = _multipart(image, upload_key, page, {})
395
+ return self._submit_files(path, resolved, files, timeout) # page size rides on resolved
396
+
397
+ def _submit_prepared(
398
+ self,
399
+ path: str,
400
+ resolved: ResolvedInput,
401
+ extra: Mapping[str, str],
402
+ timeout: float | None,
403
+ ) -> MaskBytes:
404
+ files: dict[str, Any] = {name: (None, value) for name, value in extra.items()}
405
+ files["image"] = resolved.part
406
+ return self._submit_files(
407
+ path, resolved, files, timeout, page_size_pt=resolved.page_size_pt
408
+ )
409
+
410
+ def _submit_files(
411
+ self,
412
+ path: str,
413
+ resolved: ResolvedInput | None,
414
+ files: dict[str, Any],
415
+ timeout: float | None,
416
+ page_size_pt: tuple[float, float] | None = None,
417
+ ) -> MaskBytes:
418
+ if resolved is not None and page_size_pt is None:
419
+ page_size_pt = resolved.page_size_pt
420
+ try:
421
+ response = self._request("POST", path, files=files, timeout=timeout, rewind=resolved)
422
+ finally:
423
+ if resolved is not None:
424
+ resolved.close()
425
+ return mask_from_response(response.content, response.headers, page_size_pt)
426
+
427
+ def _request(
428
+ self,
429
+ method: str,
430
+ path: str,
431
+ *,
432
+ files: Mapping[str, Any] | None = None,
433
+ json_body: Any | None = None,
434
+ timeout: float | None = None,
435
+ rewind: ResolvedInput | None = None,
436
+ ) -> requests.Response:
437
+ url = build_url(self.base_url, path)
438
+ request_timeout = self.timeout if timeout is None else timeout
439
+ attempt = 0
440
+ while True:
441
+ if attempt and rewind is not None:
442
+ rewind.rewind()
443
+ try:
444
+ response = self._session.request(
445
+ method=method,
446
+ url=url,
447
+ headers=build_headers(self.api_key),
448
+ files=files,
449
+ json=json_body,
450
+ timeout=request_timeout,
451
+ )
452
+ except requests.Timeout as exc:
453
+ if attempt < self.max_retries:
454
+ time.sleep(retry_delay(attempt, self.retry_backoff))
455
+ attempt += 1
456
+ continue
457
+ raise ApiTimeoutError(str(exc)) from exc
458
+ except requests.ConnectionError as exc:
459
+ if attempt < self.max_retries:
460
+ time.sleep(retry_delay(attempt, self.retry_backoff))
461
+ attempt += 1
462
+ continue
463
+ raise ApiConnectionError(str(exc)) from exc
464
+
465
+ if response.status_code < 400:
466
+ return response
467
+
468
+ body = decode_json(response.text)
469
+ if attempt < self.max_retries and should_retry(response.status_code, body):
470
+ retry_after = parse_retry_after(response.headers.get("Retry-After"))
471
+ time.sleep(retry_delay(attempt, self.retry_backoff, retry_after))
472
+ attempt += 1
473
+ continue
474
+ raise error_from_response(response.status_code, response.headers, body, response.text)
475
+
476
+
477
+ def _multipart(
478
+ image: ImageInput | None,
479
+ upload_key: str | None,
480
+ page: int | None,
481
+ extra: Mapping[str, str],
482
+ ) -> tuple[ResolvedInput | None, dict[str, Any]]:
483
+ """Build the multipart ``files`` mapping for an extract/analyze call.
484
+
485
+ Every field, including plain text ones, is passed through ``files`` so
486
+ the request is always ``multipart/form-data`` regardless of whether an
487
+ image part is present.
488
+ """
489
+
490
+ if (image is None) == (upload_key is None):
491
+ raise InvalidRequestError("Pass exactly one of `image` or `upload_key`.")
492
+ files: dict[str, Any] = {name: (None, value) for name, value in extra.items()}
493
+ if upload_key is not None:
494
+ files["upload_key"] = (None, upload_key)
495
+ if page is not None and page != 1:
496
+ # The object is already in storage, so the server selects the page.
497
+ files["page"] = (None, str(page))
498
+ return None, files
499
+ # For a file we hold, the page is cut out locally and only it is sent.
500
+ resolved = prepare_input(image, page) # type: ignore[arg-type]
501
+ files["image"] = resolved.part
502
+ return resolved, files
@@ -0,0 +1,123 @@
1
+ """Exception hierarchy for the Floor Plan API client.
2
+
3
+ All exceptions raised by :class:`floorplan_api.Client` and
4
+ :class:`floorplan_api.AsyncClient` derive from :class:`FloorPlanError`.
5
+ Catch the base class to handle every API error::
6
+
7
+ from floorplan_api import Client, FloorPlanError
8
+
9
+ try:
10
+ mask = client.extract("plan.png")
11
+ except FloorPlanError as exc:
12
+ print(f"API error: {exc}")
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from typing import Any
18
+
19
+
20
+ class FloorPlanError(Exception):
21
+ """Base class for every error raised by the client.
22
+
23
+ Attributes:
24
+ message: Human-readable error message.
25
+ status_code: HTTP status code returned by the server (if any).
26
+ type: Server-provided error type (e.g. ``"authentication_error"``).
27
+ request_id: Server-provided request ID (helpful for support tickets).
28
+ job_id: The job the server created for this request, when it told
29
+ us. Set on :class:`TimeoutError` (504) and
30
+ :class:`ProcessingError` (500) so the caller can still poll
31
+ :py:meth:`~floorplan_api.Client.get_job` and download the mask.
32
+ response: Raw decoded JSON response body when available.
33
+ """
34
+
35
+ def __init__(
36
+ self,
37
+ message: str,
38
+ *,
39
+ status_code: int | None = None,
40
+ type: str | None = None,
41
+ request_id: str | None = None,
42
+ job_id: str | None = None,
43
+ response: dict[str, Any] | None = None,
44
+ ) -> None:
45
+ super().__init__(message)
46
+ self.message = message
47
+ self.status_code = status_code
48
+ self.type = type
49
+ self.request_id = request_id
50
+ self.job_id = job_id
51
+ self.response = response
52
+
53
+ def __str__(self) -> str:
54
+ parts = [self.message]
55
+ if self.status_code is not None:
56
+ parts.append(f"(status={self.status_code})")
57
+ if self.request_id:
58
+ parts.append(f"(request_id={self.request_id})")
59
+ if self.job_id:
60
+ parts.append(f"(job_id={self.job_id})")
61
+ return " ".join(parts)
62
+
63
+
64
+ class AuthenticationError(FloorPlanError):
65
+ """Raised when the API key is missing, malformed, expired, or revoked (401),
66
+ or the resource belongs to another account (403)."""
67
+
68
+
69
+ class InvalidRequestError(FloorPlanError):
70
+ """Raised when the request was malformed or the input unsupported
71
+ (400, 409, 413, 415), or when the client rejects the input before sending."""
72
+
73
+
74
+ class NotFoundError(FloorPlanError):
75
+ """Raised when a resource does not exist (404)."""
76
+
77
+
78
+ class RateLimitError(FloorPlanError):
79
+ """Raised when the rate limit was exceeded (429) and retries are exhausted.
80
+
81
+ Attributes:
82
+ retry_after: Number of seconds to wait before retrying, when provided
83
+ by the server's ``Retry-After`` header.
84
+ """
85
+
86
+ def __init__(self, message: str, *, retry_after: float | None = None, **kwargs: Any) -> None:
87
+ super().__init__(message, **kwargs)
88
+ self.retry_after = retry_after
89
+
90
+
91
+ class ServerError(FloorPlanError):
92
+ """Raised when the API returns a 5xx response after retries are exhausted.
93
+
94
+ A 503 with ``Retry-After`` (queue at capacity) is retried, honouring the
95
+ header, before this is raised.
96
+ """
97
+
98
+
99
+ class ProcessingError(ServerError):
100
+ """Raised when the worker failed the job (500 with ``job_id``).
101
+
102
+ The input was accepted and queued, but inference on it failed, for
103
+ example because the bytes could not be decoded. This is not retried:
104
+ resubmitting the same input would fail the same way. ``job_id`` and
105
+ ``type`` (``"inference_error"``, ``"storage_error"``, ...) are set.
106
+ """
107
+
108
+
109
+ class TimeoutError(FloorPlanError): # noqa: A001 — intentional shadowing for API parity
110
+ """Raised when a request exceeds the configured timeout, when
111
+ :py:meth:`~floorplan_api.Client.wait_for_job` gives up, or when the server
112
+ answers 504 because the worker did not finish within its synchronous
113
+ budget.
114
+
115
+ In the 504 case ``job_id`` is set and the job is still running: call
116
+ ``client.wait_for_job(exc.job_id)`` then ``client.download_mask(exc.job_id)``
117
+ instead of resubmitting. The client never retries a 504 that carries a
118
+ ``job_id``, because each retry would queue another copy of the job.
119
+ """
120
+
121
+
122
+ class ConnectionError(FloorPlanError): # noqa: A001 — intentional shadowing for API parity
123
+ """Raised when the underlying transport fails (DNS, TCP, TLS)."""