poli-page 0.11.0 → 0.13.0
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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +78 -0
- data/MIGRATION.md +74 -0
- data/README.md +35 -4
- data/SECURITY.md +4 -2
- data/lib/poli_page/client.rb +8 -6
- data/lib/poli_page/documents.rb +1 -1
- data/lib/poli_page/errors.rb +54 -12
- data/lib/poli_page/internal/constants.rb +8 -0
- data/lib/poli_page/internal/http.rb +2 -2
- data/lib/poli_page/internal/presigned_fetch.rb +106 -30
- data/lib/poli_page/internal/transport.rb +1 -1
- data/lib/poli_page/internal/uuid.rb +1 -1
- data/lib/poli_page/internal/wire.rb +1 -1
- data/lib/poli_page/models/document_descriptor.rb +1 -1
- data/lib/poli_page/models/document_preview_result.rb +1 -1
- data/lib/poli_page/render.rb +1 -2
- data/lib/poli_page/render_to_file.rb +97 -20
- data/lib/poli_page/retry_event.rb +1 -1
- data/lib/poli_page/version.rb +1 -1
- data/sig/poli_page/errors.rbs +10 -2
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: f6e6c0236a43dbf8ecc971781a63e99e6e878f045f4ebf9b9c86f589618f69fc
|
|
4
|
+
data.tar.gz: d4b0dc1776a2090b1dd57c5623a819048050f1188fcf8ad519a78d2c03ca481e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 9325ac500f8e8dff0f9049b14214285d877f58b01123fc8dc061fcec54db51329aaa7bc4a771ed56ee94dc987e20a84d88789bb865bb61c2df7089bf4605c259
|
|
7
|
+
data.tar.gz: 6b97af27df67085ea10b23d334a683a8f85535f454bc51ab122dd2dcb68c996f16d51f7e073f918cf9c595b71eb26b75557decf968830492dae9f4e1f48bfa12
|
data/CHANGELOG.md
CHANGED
|
@@ -10,6 +10,84 @@ Breaking releases (minor bumps while on 0.x) are summarized in
|
|
|
10
10
|
|
|
11
11
|
## [Unreleased]
|
|
12
12
|
|
|
13
|
+
## [0.13.0] - 2026-11-02
|
|
14
|
+
|
|
15
|
+
Conformance with the Poli Page API contract v0.13.0
|
|
16
|
+
([poli-page/api-contract](https://github.com/poli-page/api-contract)). The
|
|
17
|
+
SDK now shares its major.minor with the Poli Page API, so it moves from 0.11
|
|
18
|
+
to 0.13. See [MIGRATION.md](MIGRATION.md#from-0110-to-0130).
|
|
19
|
+
|
|
20
|
+
### Added
|
|
21
|
+
|
|
22
|
+
- `invalid_response`: a 2xx answer whose body is not JSON, from a method that
|
|
23
|
+
expects JSON (the API call of `render.document`, `render.pdf` and
|
|
24
|
+
`render.pdf_stream`, `documents.get`, `documents.thumbnails`), raises
|
|
25
|
+
`PoliPage::InvalidResponseError` with the code `invalid_response`, the
|
|
26
|
+
message `Invalid response: HTTP <status> body is not JSON`, the real 2xx as
|
|
27
|
+
`status`, the answer's `x-request-id` as `request_id`, and the JSON
|
|
28
|
+
parser's error as `cause`. It is never retried, `retryable?` is `false`,
|
|
29
|
+
and `on_error` fires with it. Such a body usually comes from a proxy or a
|
|
30
|
+
captive portal between the SDK and the API.
|
|
31
|
+
- `io_error`: `render_to_file` raises `PoliPage::FileWriteError` when it
|
|
32
|
+
cannot write the file (creating a parent directory, opening, writing or
|
|
33
|
+
moving it into place). No status, the message
|
|
34
|
+
`Failed to write PDF: <reason>` with Ruby's own error text, that error as
|
|
35
|
+
`cause`, never retried.
|
|
36
|
+
- `to_payload` answers `status` 502 for `invalid_response`. The error's own
|
|
37
|
+
`status` keeps the real 2xx.
|
|
38
|
+
- `scripts/release.sh` and the release workflow check that the API contract
|
|
39
|
+
pinned for the tests has the release's major.minor (see
|
|
40
|
+
[RELEASING.md](RELEASING.md)).
|
|
41
|
+
|
|
42
|
+
### Changed
|
|
43
|
+
|
|
44
|
+
- **BREAKING**: `Error#to_payload` returns `{ code:, message:, status:,
|
|
45
|
+
requestId: }`: the `:request_id` key is renamed `:requestId`, the key every
|
|
46
|
+
Poli Page SDK uses, so every framework integration answers the same JSON
|
|
47
|
+
body (`"requestId"`). Read `payload[:requestId]` instead of
|
|
48
|
+
`payload[:request_id]`. The other keys are unchanged.
|
|
49
|
+
- **BREAKING**: a 2xx whose body is not JSON (an empty body included) was
|
|
50
|
+
`PoliPage::InternalError` (`INTERNAL_ERROR`,
|
|
51
|
+
`response body was not valid JSON: …`); it is now `invalid_response`
|
|
52
|
+
(above). A 2xx JSON value that is not an object stays `INTERNAL_ERROR`.
|
|
53
|
+
- **BREAKING**: `render_to_file` failures to write the file are now
|
|
54
|
+
`io_error`, whatever the error. Before, `EACCES`, `ENOSPC`, `EISDIR`,
|
|
55
|
+
`ENOENT` and `EROFS` were `PoliPage::InvalidOptionsError`
|
|
56
|
+
(`invalid_options`, `failed to write to <path>: …`), and any other error
|
|
57
|
+
(`ENOTDIR`, `EPERM`, `IOError`…) escaped as the raw Ruby exception.
|
|
58
|
+
- `render_to_file` writes to a temporary file in the target's directory and
|
|
59
|
+
renames it over the target only once the PDF is complete. When anything
|
|
60
|
+
fails (the render, the download or the write), the target is left as it
|
|
61
|
+
was: an existing file keeps its content and a missing one is not created
|
|
62
|
+
(before, a failure deleted the target). The directory and the temporary
|
|
63
|
+
file are prepared before the render, so a path that cannot be written
|
|
64
|
+
costs no render. A new file gets the usual mode (`0666` minus the umask);
|
|
65
|
+
a replaced file keeps its own. When the target is a symbolic link, the file
|
|
66
|
+
it points to gets the PDF and the link stays a link. An existing target
|
|
67
|
+
that is not a regular file (a directory, a FIFO, a device, a socket)
|
|
68
|
+
raises `io_error` with the message
|
|
69
|
+
`Failed to write PDF: <path>: not a regular file`, and one that is not
|
|
70
|
+
writable raises `io_error` too; both fail before any render and are not
|
|
71
|
+
replaced. Because the PDF is renamed into place, `render_to_file` needs
|
|
72
|
+
write permission on the target's directory, and a replaced file is a new
|
|
73
|
+
file: it is owned by the user that runs the SDK, and a hard link to the
|
|
74
|
+
old file keeps the old content.
|
|
75
|
+
- The presigned-URL download is bounded by the client's `timeout:`. For
|
|
76
|
+
`render.pdf` and `download_pdf` it bounds the whole download, from the
|
|
77
|
+
connection to the last byte. For `render.pdf_stream` and `render_to_file`
|
|
78
|
+
it bounds the wait for the first byte of the PDF (connection, answer and
|
|
79
|
+
first chunk together), then each wait for the next chunk (an idle
|
|
80
|
+
timeout), so a large PDF read at the caller's pace is not cut off.
|
|
81
|
+
Before, `timeout:` only bounded each socket operation of the download, so
|
|
82
|
+
a slow storage could keep `render.pdf` going well past it.
|
|
83
|
+
- A download that outlasts its timeout raises `DOWNLOAD_FAILED` with the
|
|
84
|
+
message `Failed to download PDF: timeout` (was
|
|
85
|
+
`Failed to download PDF: network error`). No status, not retryable. A
|
|
86
|
+
download whose connection closes before the announced `Content-Length`
|
|
87
|
+
still raises `Failed to download PDF: network error`.
|
|
88
|
+
- A `timeout:` of zero or less makes the download fail at once with the
|
|
89
|
+
timeout message, as it already made every API call time out.
|
|
90
|
+
|
|
13
91
|
## [0.11.0] - 2026-10-30
|
|
14
92
|
|
|
15
93
|
Conformance with the Poli Page API contract v1.3.0
|
data/MIGRATION.md
CHANGED
|
@@ -6,6 +6,80 @@ This file documents breaking changes between releases of `poli-page`
|
|
|
6
6
|
0.x, a breaking change bumps the minor version; from 1.0 on, only a major
|
|
7
7
|
version does. Every breaking release comes with an entry here.
|
|
8
8
|
|
|
9
|
+
## From 0.11.0 to 0.13.0
|
|
10
|
+
|
|
11
|
+
The SDK now shares its major.minor with the Poli Page API, so there is no
|
|
12
|
+
0.12. This release follows the API contract v0.13.0. No method or option is
|
|
13
|
+
renamed; error handling and framework integrations may need a change.
|
|
14
|
+
|
|
15
|
+
### `to_payload` names its request id `requestId`
|
|
16
|
+
|
|
17
|
+
`Error#to_payload` returns the body a framework integration answers with,
|
|
18
|
+
and every Poli Page SDK now spells it the same way: exactly the keys
|
|
19
|
+
`code:`, `message:`, `status:` and `requestId:`. Its `:request_id` key is
|
|
20
|
+
renamed `:requestId`; the other keys are unchanged.
|
|
21
|
+
|
|
22
|
+
```ruby
|
|
23
|
+
# 0.11.0
|
|
24
|
+
payload = e.to_payload
|
|
25
|
+
payload[:request_id] # => "req_..."
|
|
26
|
+
render json: payload # answered {"code":…,"request_id":"req_…"}
|
|
27
|
+
|
|
28
|
+
# 0.13.0
|
|
29
|
+
payload = e.to_payload
|
|
30
|
+
payload[:requestId] # => "req_..."
|
|
31
|
+
render json: payload # answers {"code":…,"requestId":"req_…"}
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
If your integration renamed `request_id` to `requestId` itself, drop the
|
|
35
|
+
rename. Its `:status` is 502 for the new `invalid_response` (below).
|
|
36
|
+
|
|
37
|
+
### A 2xx whose body is not JSON is `invalid_response`
|
|
38
|
+
|
|
39
|
+
It was `PoliPage::InternalError` (`INTERNAL_ERROR`,
|
|
40
|
+
`response body was not valid JSON: …`). It is now
|
|
41
|
+
`PoliPage::InvalidResponseError`, code `invalid_response`, message
|
|
42
|
+
`Invalid response: HTTP <status> body is not JSON`, with the real 2xx as
|
|
43
|
+
`status` and the answer's request id. It is not retried.
|
|
44
|
+
|
|
45
|
+
```ruby
|
|
46
|
+
# 0.11.0
|
|
47
|
+
rescue PoliPage::InternalError => e
|
|
48
|
+
# a proxy answered instead of the API, if e.status is a 2xx
|
|
49
|
+
|
|
50
|
+
# 0.13.0
|
|
51
|
+
rescue PoliPage::InvalidResponseError => e
|
|
52
|
+
# a proxy answered instead of the API; e.status is its 2xx
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
### `render_to_file` raises `io_error`
|
|
56
|
+
|
|
57
|
+
A file that cannot be written was `PoliPage::InvalidOptionsError` for
|
|
58
|
+
`EACCES`, `ENOSPC`, `EISDIR`, `ENOENT` and `EROFS`, and the raw Ruby
|
|
59
|
+
exception for anything else; it is now always `PoliPage::FileWriteError`,
|
|
60
|
+
code `io_error`, with the message
|
|
61
|
+
`Failed to write PDF: <reason>` and Ruby's error as `cause`. The target is
|
|
62
|
+
left untouched on any failure: it is no longer deleted when the render
|
|
63
|
+
fails.
|
|
64
|
+
|
|
65
|
+
```ruby
|
|
66
|
+
# 0.13.0
|
|
67
|
+
rescue PoliPage::FileWriteError => e
|
|
68
|
+
# disk full, permissions, a missing mount...
|
|
69
|
+
logger.error(e.cause)
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
### The PDF download is bounded by `timeout:`
|
|
73
|
+
|
|
74
|
+
The presigned-URL download now uses the client's `timeout:` (60 s by
|
|
75
|
+
default). `render.pdf` and `download_pdf` bound the whole download, so raise
|
|
76
|
+
`timeout:` if your PDFs take longer than that to download.
|
|
77
|
+
`render.pdf_stream` and `render_to_file` use it as an idle timeout, the wait
|
|
78
|
+
for the first byte of the PDF and each gap between two chunks, and are never cut off for
|
|
79
|
+
their total length. Past the timeout the error is `DOWNLOAD_FAILED` with the
|
|
80
|
+
message `Failed to download PDF: timeout` (was
|
|
81
|
+
`Failed to download PDF: network error`).
|
|
82
|
+
|
|
9
83
|
## From 0.10.0 to 0.11.0
|
|
10
84
|
|
|
11
85
|
The SDK now follows the Poli Page API contract v1.3.0, the same retry policy,
|
data/README.md
CHANGED
|
@@ -91,9 +91,18 @@ client.render_to_file(
|
|
|
91
91
|
)
|
|
92
92
|
```
|
|
93
93
|
|
|
94
|
-
`render_to_file` streams the response bytes
|
|
94
|
+
`render_to_file` streams the response bytes to disk via
|
|
95
95
|
`render.pdf_stream` — bounded memory regardless of PDF size. Parent
|
|
96
|
-
directories are created on the fly.
|
|
96
|
+
directories are created on the fly. The PDF goes to a temporary file next
|
|
97
|
+
to the target, renamed over it once complete, so a failed render, download
|
|
98
|
+
or write leaves the target as it was. This needs write permission on the
|
|
99
|
+
target's directory, and a replaced file is a new file (your process owns
|
|
100
|
+
it; a hard link to the old one keeps the old content). A symbolic link is
|
|
101
|
+
followed: the file it points to gets the PDF. A target that is not a
|
|
102
|
+
regular file (a directory, a FIFO, a device) fails before any render with
|
|
103
|
+
`Failed to write PDF: <path>: not a regular file`. A file that cannot be written raises
|
|
104
|
+
`PoliPage::FileWriteError` (code `io_error`, message
|
|
105
|
+
`Failed to write PDF: <reason>`, the Ruby error as `cause`).
|
|
97
106
|
|
|
98
107
|
### Try it locally — runnable demo
|
|
99
108
|
|
|
@@ -170,7 +179,11 @@ client.documents.delete(doc.document_id)
|
|
|
170
179
|
|
|
171
180
|
The presigned URL has a 15-minute TTL. If `download_pdf` raises
|
|
172
181
|
`PoliPage::DownloadError` with `status: 403` from S3, call
|
|
173
|
-
`documents.get(id)` to refresh and retry.
|
|
182
|
+
`documents.get(id)` to refresh and retry. The client's `timeout:` also
|
|
183
|
+
bounds the download: the whole download for `download_pdf` and
|
|
184
|
+
`render.pdf`, and the wait for the first byte, then each wait for the next
|
|
185
|
+
chunk, for `render.pdf_stream` and `render_to_file`. Past it, `DownloadError` has the message
|
|
186
|
+
`Failed to download PDF: timeout`.
|
|
174
187
|
|
|
175
188
|
## Authentication & environments
|
|
176
189
|
|
|
@@ -222,7 +235,7 @@ through as a `Bearer` token and does not check its format.
|
|
|
222
235
|
| `base_url:` | `String` | `"https://api.poli.page"` | API base URL |
|
|
223
236
|
| `max_retries:`| `Integer` | `2` | Max retry attempts on retryable errors |
|
|
224
237
|
| `retry_delay:`| `Numeric` (seconds) | `0.5` | Base delay before the first retry |
|
|
225
|
-
| `timeout:` | `Numeric` (seconds) | `60` | Per-attempt timeout, body included
|
|
238
|
+
| `timeout:` | `Numeric` (seconds) | `60` | Per-attempt timeout, body included; also bounds the PDF download |
|
|
226
239
|
| `logger:` | `Logger` (any duck type) | `nil` | Hook errors and DEBUG events here |
|
|
227
240
|
| `on_retry:` | `#call(PoliPage::RetryEvent)`| `nil` | Called when a retry is scheduled |
|
|
228
241
|
| `on_error:` | `#call(PoliPage::Error)` | `nil` | Called when a call terminates in error |
|
|
@@ -293,6 +306,24 @@ end
|
|
|
293
306
|
The full list of known API codes lives in `PoliPage::ErrorCodes` (see
|
|
294
307
|
`lib/poli_page/errors.rb`).
|
|
295
308
|
|
|
309
|
+
A 2xx answer whose body is not JSON, which comes from something between
|
|
310
|
+
your app and the API (a proxy, a captive portal), raises
|
|
311
|
+
`PoliPage::InvalidResponseError` (code `invalid_response`, message
|
|
312
|
+
`Invalid response: HTTP <status> body is not JSON`) with the real 2xx as
|
|
313
|
+
`status`. It is not retried.
|
|
314
|
+
|
|
315
|
+
For framework integrations, `e.to_payload` returns the HTTP body to answer
|
|
316
|
+
with: exactly the keys `code:`, `message:`, `status:` and `requestId:`, the
|
|
317
|
+
same JSON in every Poli Page SDK. Its `status:` is the API status, 503 for
|
|
318
|
+
`network_error`, 504 for `timeout` and 502 for `invalid_response`.
|
|
319
|
+
|
|
320
|
+
```ruby
|
|
321
|
+
rescue PoliPage::Error => e
|
|
322
|
+
payload = e.to_payload
|
|
323
|
+
render json: payload, status: payload[:status] || 500
|
|
324
|
+
end
|
|
325
|
+
```
|
|
326
|
+
|
|
296
327
|
→ Full error reference: <https://poli-page.github.io/sdk-ruby/reference/errors/>
|
|
297
328
|
|
|
298
329
|
## Cancellation
|
data/SECURITY.md
CHANGED
|
@@ -81,8 +81,10 @@ updates. Older minors do not receive backports — please upgrade.
|
|
|
81
81
|
is pinned to a 40-character SHA, not a floating tag.
|
|
82
82
|
- **Dependabot** — weekly updates for `bundler` and `github-actions`.
|
|
83
83
|
- **MFA-required publishing** — the gemspec declares
|
|
84
|
-
`rubygems_mfa_required: "true"
|
|
85
|
-
|
|
84
|
+
`rubygems_mfa_required: "true"`. Releases are published by the tag-triggered
|
|
85
|
+
release workflow via RubyGems Trusted Publishing (OIDC), behind the
|
|
86
|
+
`rubygems-release` environment's required-reviewer approval; no RubyGems
|
|
87
|
+
API key is stored anywhere.
|
|
86
88
|
- **Restricted workflow permissions** — every workflow declares the
|
|
87
89
|
minimum `permissions:` block (most are `contents: read`).
|
|
88
90
|
|
data/lib/poli_page/client.rb
CHANGED
|
@@ -77,7 +77,7 @@ module PoliPage
|
|
|
77
77
|
|
|
78
78
|
# Redacted `#inspect`. Ruby's default would dump `@api_key` into any
|
|
79
79
|
# `puts client` or exception backtrace — that's how secrets end up in
|
|
80
|
-
# logs
|
|
80
|
+
# logs.
|
|
81
81
|
def inspect
|
|
82
82
|
"#<PoliPage::Client base_url=#{@base_url.inspect} timeout=#{@timeout} max_retries=#{@max_retries}>"
|
|
83
83
|
end
|
|
@@ -220,17 +220,19 @@ module PoliPage
|
|
|
220
220
|
{ ok: false, error: error, retry_after: retry_after, retryable: retryable }
|
|
221
221
|
end
|
|
222
222
|
|
|
223
|
-
# A 2xx whose body is not
|
|
224
|
-
#
|
|
225
|
-
#
|
|
223
|
+
# A 2xx whose body is not JSON (an empty body included) raises
|
|
224
|
+
# `invalid_response` with the real status and the answer's request id; a
|
|
225
|
+
# JSON value that is not an object raises `INTERNAL_ERROR`. Neither is
|
|
226
|
+
# retried, and both go through `on_error` like any error the SDK is about
|
|
227
|
+
# to raise.
|
|
226
228
|
def parse_json_response(response)
|
|
227
229
|
parsed = JSON.parse(response.body.to_s)
|
|
228
230
|
return Internal::Wire.from_wire(parsed, Internal::Wire::PAYLOAD_KEYS) if parsed.is_a?(Hash)
|
|
229
231
|
|
|
230
232
|
raise_terminal(PoliPage::InternalError.new("response body was not a JSON object", status: response.status))
|
|
231
233
|
rescue JSON::ParserError => e
|
|
232
|
-
|
|
233
|
-
|
|
234
|
+
request_id = response.headers[Internal::Constants::HEADER_REQUEST_ID]
|
|
235
|
+
raise_terminal(PoliPage::InvalidResponseError.new(status: response.status, request_id: request_id, cause: e))
|
|
234
236
|
end
|
|
235
237
|
|
|
236
238
|
def fire_hook(hook, event)
|
data/lib/poli_page/documents.rb
CHANGED
|
@@ -8,7 +8,7 @@ require_relative "models/thumbnail"
|
|
|
8
8
|
|
|
9
9
|
module PoliPage
|
|
10
10
|
module Resources
|
|
11
|
-
# `client.documents` namespace (
|
|
11
|
+
# `client.documents` namespace (port of
|
|
12
12
|
# sdk-node/src/documents.ts).
|
|
13
13
|
class Documents
|
|
14
14
|
def initialize(client)
|
data/lib/poli_page/errors.rb
CHANGED
|
@@ -53,27 +53,34 @@ module PoliPage
|
|
|
53
53
|
|
|
54
54
|
# A network error, a timeout, a 5xx or a 429, except the codes the SDK
|
|
55
55
|
# never retries: `QUOTA_EXCEEDED`, `OVERAGE_CAP_EXCEEDED`,
|
|
56
|
-
# `STORAGE_ACCESS_DENIED`,
|
|
57
|
-
#
|
|
56
|
+
# `STORAGE_ACCESS_DENIED`, `DOWNLOAD_FAILED` whatever the storage status
|
|
57
|
+
# (the presigned-URL download is never retried), `invalid_response` and
|
|
58
|
+
# `io_error`.
|
|
58
59
|
def retryable?
|
|
59
|
-
return false if Internal::Constants::NON_RETRYABLE_CODES.include?(@code) ||
|
|
60
|
+
return false if Internal::Constants::NON_RETRYABLE_CODES.include?(@code) ||
|
|
61
|
+
Internal::Constants::NEVER_RETRIED_SDK_CODES.include?(@code)
|
|
60
62
|
return true if network_error?
|
|
61
63
|
|
|
62
64
|
!@status.nil? && (@status >= 500 || @status == 429)
|
|
63
65
|
end
|
|
64
66
|
|
|
65
|
-
#
|
|
66
|
-
# `
|
|
67
|
-
#
|
|
68
|
-
# status
|
|
69
|
-
#
|
|
70
|
-
#
|
|
67
|
+
# The HTTP body a framework integration answers with: exactly the four
|
|
68
|
+
# keys `code`, `message`, `status` and `requestId`, spelled the same in
|
|
69
|
+
# every Poli Page SDK so that every integration answers the same JSON.
|
|
70
|
+
# `status` is the API status for status-bearing errors, 503 for
|
|
71
|
+
# `network_error`, 504 for `timeout`, 502 for `invalid_response`, and nil
|
|
72
|
+
# when unknown; `requestId` is nil when unknown. The {#status} reader
|
|
73
|
+
# itself is unchanged: nil for transport errors, the real 2xx for
|
|
74
|
+
# `invalid_response`.
|
|
75
|
+
#
|
|
76
|
+
# @return [Hash{Symbol=>(String, Integer, nil)}]
|
|
77
|
+
# `{ code:, message:, status:, requestId: }`
|
|
71
78
|
def to_payload
|
|
72
79
|
{
|
|
73
80
|
code: @code,
|
|
74
81
|
message: message,
|
|
75
82
|
status: payload_status,
|
|
76
|
-
|
|
83
|
+
requestId: @request_id
|
|
77
84
|
}
|
|
78
85
|
end
|
|
79
86
|
|
|
@@ -144,11 +151,15 @@ module PoliPage
|
|
|
144
151
|
# `DOWNLOAD_FAILED`: the presigned-URL download failed. With the storage's
|
|
145
152
|
# `status` when it answered (`Failed to download PDF: HTTP <status>`),
|
|
146
153
|
# without one when no complete answer came (`Failed to download PDF:
|
|
147
|
-
# network error`, the HTTP library's exception as {#cause})
|
|
154
|
+
# network error`, the HTTP library's exception as {#cause}) or when the
|
|
155
|
+
# client's `timeout` elapsed (`timeout: true`, `Failed to download PDF:
|
|
156
|
+
# timeout`). Never retried, and {#retryable?} is false whatever the status.
|
|
148
157
|
class DownloadError < Error
|
|
149
|
-
def initialize(status: nil, cause: nil, message: nil)
|
|
158
|
+
def initialize(status: nil, cause: nil, message: nil, timeout: false)
|
|
150
159
|
message ||= if status
|
|
151
160
|
format(Internal::Constants::MESSAGE_DOWNLOAD_FAILED, status: status)
|
|
161
|
+
elsif timeout
|
|
162
|
+
Internal::Constants::MESSAGE_DOWNLOAD_FAILED_TIMEOUT
|
|
152
163
|
else
|
|
153
164
|
Internal::Constants::MESSAGE_DOWNLOAD_FAILED_NETWORK
|
|
154
165
|
end
|
|
@@ -156,6 +167,37 @@ module PoliPage
|
|
|
156
167
|
end
|
|
157
168
|
end
|
|
158
169
|
|
|
170
|
+
# `invalid_response`: a 2xx answer whose body is not JSON, from a method
|
|
171
|
+
# that expects JSON (the API call of `render.document`, `render.pdf` and
|
|
172
|
+
# `render.pdf_stream`, `documents.get`, `documents.thumbnails`). Such a body
|
|
173
|
+
# comes from something between the SDK and the API, such as a proxy or a
|
|
174
|
+
# captive portal. `status` is the real 2xx and `request_id` the answer's
|
|
175
|
+
# `x-request-id`; the JSON parser's error is {#cause}. Never retried.
|
|
176
|
+
# {#to_payload} answers 502: an integration must not answer an error with a
|
|
177
|
+
# 2xx.
|
|
178
|
+
class InvalidResponseError < Error
|
|
179
|
+
def initialize(status:, request_id: nil, cause: nil)
|
|
180
|
+
super(format(Internal::Constants::MESSAGE_INVALID_RESPONSE, status: status),
|
|
181
|
+
code: "invalid_response", status: status, request_id: request_id, cause: cause)
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def payload_status
|
|
185
|
+
502
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
# `io_error`: `Client#render_to_file` could not write the file (creating a
|
|
190
|
+
# parent directory, opening, writing or moving it into place). Message
|
|
191
|
+
# `Failed to write PDF: <reason>`, `<reason>` being the Ruby error's own
|
|
192
|
+
# message; that error is {#cause}. No status, never retried. The target
|
|
193
|
+
# file is left untouched.
|
|
194
|
+
class FileWriteError < Error
|
|
195
|
+
def initialize(cause:)
|
|
196
|
+
super(format(Internal::Constants::MESSAGE_IO_ERROR, reason: cause.message),
|
|
197
|
+
code: "io_error", status: nil, request_id: nil, cause: cause)
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
|
|
159
201
|
class InternalError < Error
|
|
160
202
|
def initialize(message, status: nil, cause: nil)
|
|
161
203
|
super(message, code: "INTERNAL_ERROR", status: status, request_id: nil, cause: cause)
|
|
@@ -24,6 +24,11 @@ module PoliPage
|
|
|
24
24
|
# the bucket access is restored.
|
|
25
25
|
NON_RETRYABLE_CODES = %w[QUOTA_EXCEEDED OVERAGE_CAP_EXCEEDED STORAGE_ACCESS_DENIED].freeze
|
|
26
26
|
|
|
27
|
+
# SDK codes never retried and never `retryable?`: the presigned-URL
|
|
28
|
+
# download, a 2xx whose body is not JSON (whatever answered it will
|
|
29
|
+
# answer the same again) and a file that cannot be written.
|
|
30
|
+
NEVER_RETRIED_SDK_CODES = %w[DOWNLOAD_FAILED invalid_response io_error].freeze
|
|
31
|
+
|
|
27
32
|
# SDK error messages: exact texts of sdk-specification.md §8.3, the same
|
|
28
33
|
# in every SDK (the API contract's `policy.sdkErrors` compares them).
|
|
29
34
|
MESSAGE_INVALID_OPTIONS = "apiKey is required"
|
|
@@ -31,6 +36,9 @@ module PoliPage
|
|
|
31
36
|
MESSAGE_TIMEOUT = "Request timed out after %<timeout_ms>dms"
|
|
32
37
|
MESSAGE_DOWNLOAD_FAILED = "Failed to download PDF: HTTP %<status>d"
|
|
33
38
|
MESSAGE_DOWNLOAD_FAILED_NETWORK = "Failed to download PDF: network error"
|
|
39
|
+
MESSAGE_DOWNLOAD_FAILED_TIMEOUT = "Failed to download PDF: timeout"
|
|
40
|
+
MESSAGE_INVALID_RESPONSE = "Invalid response: HTTP %<status>d body is not JSON"
|
|
41
|
+
MESSAGE_IO_ERROR = "Failed to write PDF: %<reason>s"
|
|
34
42
|
MESSAGE_PROJECT_REQUIRED = "project is required"
|
|
35
43
|
|
|
36
44
|
# Header names (lowercase form — Net::HTTP normalises to lowercase on
|
|
@@ -10,7 +10,7 @@ module PoliPage
|
|
|
10
10
|
# @api private
|
|
11
11
|
#
|
|
12
12
|
# Pure transport helpers — no I/O, no socket access. Mirrors
|
|
13
|
-
# `sdk-node/src/internal/http.ts
|
|
13
|
+
# `sdk-node/src/internal/http.ts`.
|
|
14
14
|
module HTTP
|
|
15
15
|
module_function
|
|
16
16
|
|
|
@@ -115,7 +115,7 @@ module PoliPage
|
|
|
115
115
|
end
|
|
116
116
|
|
|
117
117
|
# Map an API response (status + parsed code/message) to the most
|
|
118
|
-
# specific `PoliPage::Error` subclass
|
|
118
|
+
# specific `PoliPage::Error` subclass.
|
|
119
119
|
#
|
|
120
120
|
# @param status [Integer]
|
|
121
121
|
# @param code [String]
|
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "net/http"
|
|
4
|
+
require "timeout"
|
|
4
5
|
require "uri"
|
|
5
6
|
|
|
7
|
+
require_relative "http"
|
|
6
8
|
require_relative "transport"
|
|
9
|
+
require_relative "watchdog"
|
|
7
10
|
|
|
8
11
|
module PoliPage
|
|
9
12
|
module Internal
|
|
@@ -11,61 +14,134 @@ module PoliPage
|
|
|
11
14
|
#
|
|
12
15
|
# Mixin used by `PoliPage::Client` for the unauthenticated presigned-URL
|
|
13
16
|
# second hop (DocumentDescriptor#download_pdf, render.pdf, pdf_stream).
|
|
14
|
-
# The presigned URL is signed by
|
|
15
|
-
#
|
|
16
|
-
#
|
|
17
|
+
# The presigned URL is signed by the storage and is never retried by the
|
|
18
|
+
# SDK: the `Net::HTTP` it uses has `max_retries` 0, so it never sends the
|
|
19
|
+
# GET twice either.
|
|
17
20
|
#
|
|
18
21
|
# Every failure is `PoliPage::DownloadError` (`DOWNLOAD_FAILED`): with the
|
|
19
|
-
# storage status on a non-2xx answer, with the
|
|
20
|
-
#
|
|
22
|
+
# storage status on a non-2xx answer, with the timeout message when the
|
|
23
|
+
# client's `timeout` elapsed, and with the network message when no
|
|
24
|
+
# complete answer came otherwise (a connection error, or a body cut short
|
|
25
|
+
# of its `Content-Length`).
|
|
26
|
+
#
|
|
27
|
+
# The client's `timeout` (seconds) bounds the download:
|
|
28
|
+
#
|
|
29
|
+
# - {#fetch_bytes} (`download_pdf`, `render.pdf`): the whole download,
|
|
30
|
+
# from opening the connection to the last byte of the body.
|
|
31
|
+
# - {#stream_bytes} (`render.pdf_stream`, `render_to_file`): an idle
|
|
32
|
+
# timeout. Opening the connection, the request, the answer's head and
|
|
33
|
+
# the first chunk of the body share one deadline; then each wait for
|
|
34
|
+
# the next chunk is bounded on its own (`Net::HTTP`'s `read_timeout`),
|
|
35
|
+
# so a large PDF read at the caller's pace is not cut off. Time spent
|
|
36
|
+
# in the caller's block is never counted.
|
|
37
|
+
#
|
|
38
|
+
# A failure is labelled a timeout only when one of those bounds fired:
|
|
39
|
+
# `Net::HTTP`'s own `Net::OpenTimeout` / `Net::ReadTimeout` /
|
|
40
|
+
# `Net::WriteTimeout`, or the {Watchdog} closing the connection at the
|
|
41
|
+
# deadline. A `timeout` of zero or less has already passed, so the
|
|
42
|
+
# download fails at once with the timeout message, as an API call does.
|
|
21
43
|
#
|
|
22
44
|
# Honors the same proxy/CA configuration as {PoliPage::Internal::Transport}
|
|
23
45
|
# — reads `@proxy`, `@ca_file`, `@ca_path`, `@timeout` from the including
|
|
24
|
-
# `Client` instance.
|
|
25
|
-
# every read), not the whole download, so a long `pdf_stream` is not cut.
|
|
46
|
+
# `Client` instance.
|
|
26
47
|
module PresignedFetch
|
|
27
48
|
# Fetch the entire body of `url`. Returns the raw bytes; raises
|
|
28
|
-
# PoliPage::DownloadError on non-2xx
|
|
49
|
+
# PoliPage::DownloadError on non-2xx, network failure or timeout.
|
|
29
50
|
def fetch_bytes(url)
|
|
30
51
|
uri = URI.parse(url)
|
|
31
52
|
http = presigned_http(uri)
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
53
|
+
download = new_download
|
|
54
|
+
presigned_request(http, download) do
|
|
55
|
+
watchdog = download[:watchdog] = Watchdog.new(presigned_time_left(download)) { http.finish }
|
|
56
|
+
begin
|
|
57
|
+
http.request(Net::HTTP::Get.new(uri.request_uri)) do |res|
|
|
58
|
+
guard_presigned_status!(res)
|
|
59
|
+
res.read_body
|
|
60
|
+
end.body
|
|
61
|
+
ensure
|
|
62
|
+
watchdog.disarm
|
|
36
63
|
end
|
|
37
64
|
end
|
|
38
|
-
response.body
|
|
39
|
-
rescue *Transport::NETWORK_ERRORS => e
|
|
40
|
-
raise PoliPage::DownloadError.new(cause: e)
|
|
41
65
|
end
|
|
42
66
|
|
|
43
67
|
# Stream the body of `url` to the block. Raises PoliPage::DownloadError
|
|
44
|
-
# on non-2xx
|
|
45
|
-
#
|
|
46
|
-
# guard). An error
|
|
68
|
+
# on non-2xx, network failure or timeout (before the first chunk, or
|
|
69
|
+
# between two chunks), and PoliPage::InternalError on Content-Length: 0
|
|
70
|
+
# (port of Node `render.ts:128-130` `!response.body` guard). An error
|
|
71
|
+
# raised by the block itself propagates unchanged.
|
|
47
72
|
def stream_bytes(url, &block)
|
|
48
73
|
uri = URI.parse(url)
|
|
49
74
|
http = presigned_http(uri)
|
|
50
|
-
|
|
51
|
-
http
|
|
75
|
+
download = new_download
|
|
76
|
+
presigned_request(http, download) { stream_body(http, uri, download, block) }
|
|
77
|
+
nil
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
private
|
|
81
|
+
|
|
82
|
+
def new_download
|
|
83
|
+
{ deadline: HTTP.monotonic_now + @timeout, watchdog: nil, in_block: false, first_chunk_in: false }
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# The first-byte watchdog bounds the request, the answer's head and the
|
|
87
|
+
# wait for the first chunk of the body; it is disarmed when that chunk
|
|
88
|
+
# arrives, and `read_timeout` then bounds each wait for the next one.
|
|
89
|
+
# The `ensure` disarms it for an empty body or an error.
|
|
90
|
+
def stream_body(http, uri, download, block)
|
|
91
|
+
watchdog = download[:watchdog] = Watchdog.new(presigned_time_left(download)) { http.finish }
|
|
92
|
+
begin
|
|
52
93
|
http.request(Net::HTTP::Get.new(uri.request_uri)) do |response|
|
|
53
94
|
guard_stream!(response)
|
|
54
|
-
response.read_body
|
|
55
|
-
in_block = true
|
|
56
|
-
block.call(chunk)
|
|
57
|
-
in_block = false
|
|
58
|
-
end
|
|
95
|
+
response.read_body { |chunk| yield_chunk(download, block, chunk) }
|
|
59
96
|
end
|
|
97
|
+
ensure
|
|
98
|
+
watchdog.disarm
|
|
60
99
|
end
|
|
61
|
-
|
|
62
|
-
rescue *Transport::NETWORK_ERRORS => e
|
|
63
|
-
raise if in_block
|
|
100
|
+
end
|
|
64
101
|
|
|
65
|
-
|
|
102
|
+
def yield_chunk(download, block, chunk)
|
|
103
|
+
unless download[:first_chunk_in]
|
|
104
|
+
download[:watchdog].disarm
|
|
105
|
+
download[:first_chunk_in] = true
|
|
106
|
+
end
|
|
107
|
+
download[:in_block] = true
|
|
108
|
+
block.call(chunk)
|
|
109
|
+
download[:in_block] = false
|
|
66
110
|
end
|
|
67
111
|
|
|
68
|
-
|
|
112
|
+
# Opens the connection within the time left, runs the request, closes
|
|
113
|
+
# the connection, and turns a failure of the download into a
|
|
114
|
+
# `DownloadError`. A `PoliPage::Error` (a non-2xx status) and an error
|
|
115
|
+
# raised by the caller's block go through unchanged.
|
|
116
|
+
def presigned_request(http, download)
|
|
117
|
+
Timeout.timeout(presigned_time_left(download), Net::OpenTimeout) { http.start }
|
|
118
|
+
begin
|
|
119
|
+
yield
|
|
120
|
+
ensure
|
|
121
|
+
http.finish if http.started?
|
|
122
|
+
end
|
|
123
|
+
rescue StandardError => e
|
|
124
|
+
raise if e.is_a?(PoliPage::Error) || download[:in_block]
|
|
125
|
+
|
|
126
|
+
raise_download_failure(e, download)
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# Once the watchdog has closed the connection, whatever `Net::HTTP`
|
|
130
|
+
# raises next is that timeout.
|
|
131
|
+
def raise_download_failure(error, download)
|
|
132
|
+
timed_out = download[:watchdog]&.fired? || Transport::SOCKET_TIMEOUTS.any? { |klass| error.is_a?(klass) }
|
|
133
|
+
raise PoliPage::DownloadError.new(timeout: true, cause: error) if timed_out
|
|
134
|
+
raise error unless Transport::NETWORK_ERRORS.any? { |klass| error.is_a?(klass) }
|
|
135
|
+
|
|
136
|
+
raise PoliPage::DownloadError.new(cause: error)
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def presigned_time_left(download)
|
|
140
|
+
left = download[:deadline] - HTTP.monotonic_now
|
|
141
|
+
raise Net::OpenTimeout, "the download's timeout has passed" unless left.positive?
|
|
142
|
+
|
|
143
|
+
left
|
|
144
|
+
end
|
|
69
145
|
|
|
70
146
|
def presigned_http(uri)
|
|
71
147
|
Transport.build_http(uri, timeout: @timeout, proxy: @proxy, ca_file: @ca_file, ca_path: @ca_path)
|
|
@@ -14,7 +14,7 @@ module PoliPage
|
|
|
14
14
|
# @api private
|
|
15
15
|
#
|
|
16
16
|
# Thin wrapper around `Net::HTTP`. The ONLY module that opens sockets to
|
|
17
|
-
# the API
|
|
17
|
+
# the API. Translates network-level exceptions into
|
|
18
18
|
# the `PoliPage::Error` hierarchy at the seam (§8 error mapping).
|
|
19
19
|
#
|
|
20
20
|
# Each attempt opens its own connection and reads the whole answer, body
|
|
@@ -8,7 +8,7 @@ module PoliPage
|
|
|
8
8
|
#
|
|
9
9
|
# Thin wrapper over `SecureRandom.uuid`. Indirection exists so a future
|
|
10
10
|
# swap (e.g. UUIDv7 once the stdlib lands `SecureRandom.uuid_v7`) is a
|
|
11
|
-
# one-file change
|
|
11
|
+
# one-file change.
|
|
12
12
|
module UUID
|
|
13
13
|
module_function
|
|
14
14
|
|
|
@@ -5,7 +5,7 @@ module PoliPage
|
|
|
5
5
|
# @api private
|
|
6
6
|
#
|
|
7
7
|
# snake_case ↔ camelCase translator for the SDK's own field names
|
|
8
|
-
# (sdk-
|
|
8
|
+
# (sdk-specification.md §2). Pure, no runtime
|
|
9
9
|
# dependency on metaprogramming DSLs. Outgoing: hash keys are stringified
|
|
10
10
|
# and camelized. Incoming: hash keys are snake_cased and symbolized.
|
|
11
11
|
#
|
|
@@ -6,7 +6,7 @@ module PoliPage
|
|
|
6
6
|
# SDK-attached `_client` back-reference used by `#download_pdf`.
|
|
7
7
|
#
|
|
8
8
|
# The `_client` field is hidden from `#to_h` and `#inspect` so it doesn't
|
|
9
|
-
# leak into logs or marshalled representations
|
|
9
|
+
# leak into logs or marshalled representations.
|
|
10
10
|
DocumentDescriptor = Data.define(
|
|
11
11
|
:document_id,
|
|
12
12
|
:organization_id,
|
|
@@ -4,6 +4,6 @@ module PoliPage
|
|
|
4
4
|
# Return value of `client.documents.preview`: the stored HTML of an
|
|
5
5
|
# already rendered document, with `page_count` read from the
|
|
6
6
|
# `X-Document-Page-Count` response header on a `text/html` body
|
|
7
|
-
# (
|
|
7
|
+
# (mirrors Node `documents.ts:75-77`).
|
|
8
8
|
DocumentPreviewResult = Data.define(:html, :page_count)
|
|
9
9
|
end
|
data/lib/poli_page/render.rb
CHANGED
|
@@ -5,8 +5,7 @@ require_relative "models/document_descriptor"
|
|
|
5
5
|
module PoliPage
|
|
6
6
|
module Resources
|
|
7
7
|
# `client.render` namespace. Each method captures a back-reference to the
|
|
8
|
-
# parent `PoliPage::Client` and delegates HTTP execution through it
|
|
9
|
-
# (sdk-ruby-plan.md §3.1).
|
|
8
|
+
# parent `PoliPage::Client` and delegates HTTP execution through it.
|
|
10
9
|
class Render
|
|
11
10
|
def initialize(client)
|
|
12
11
|
@client = client
|
|
@@ -2,19 +2,30 @@
|
|
|
2
2
|
|
|
3
3
|
require "fileutils"
|
|
4
4
|
require "pathname"
|
|
5
|
+
require "tempfile"
|
|
5
6
|
|
|
6
7
|
module PoliPage
|
|
7
8
|
class Client
|
|
8
|
-
# Render a PDF and stream the bytes
|
|
9
|
+
# Render a PDF and stream the bytes to `path`. Built on
|
|
9
10
|
# `render.pdf_stream`, so memory usage stays bounded regardless of
|
|
10
|
-
# document size. Creates parent directories if missing
|
|
11
|
-
# existing
|
|
12
|
-
#
|
|
11
|
+
# document size. Creates parent directories if missing, and replaces an
|
|
12
|
+
# existing file.
|
|
13
|
+
#
|
|
14
|
+
# The PDF is written to a temporary file in the target's directory, which
|
|
15
|
+
# is renamed over `path` only once the whole PDF is in. When anything
|
|
16
|
+
# fails (the render, the download or the write), `path` is left as it
|
|
17
|
+
# was: an existing file keeps its content, a missing one is not created.
|
|
18
|
+
# The directory and the temporary file are prepared before the render,
|
|
19
|
+
# so a path that cannot be written costs no render. When `path` is a
|
|
20
|
+
# symbolic link, the PDF goes to the file it points to, and the link
|
|
21
|
+
# stays a link.
|
|
13
22
|
#
|
|
14
23
|
# @param path [String, Pathname]
|
|
15
24
|
# @param kwargs forwarded to `Resources::Render#pdf_stream`
|
|
16
25
|
# @return [nil]
|
|
17
|
-
# @raise [PoliPage::
|
|
26
|
+
# @raise [PoliPage::FileWriteError] `io_error` when the file cannot be written (creating a
|
|
27
|
+
# parent directory, opening, writing or moving it into place): message
|
|
28
|
+
# `Failed to write PDF: <reason>`, the Ruby error as `cause`, no status, never retried
|
|
18
29
|
# @raise any error raised by `Resources::Render#pdf_stream`
|
|
19
30
|
#
|
|
20
31
|
# @example Write a PDF to disk
|
|
@@ -25,27 +36,93 @@ module PoliPage
|
|
|
25
36
|
# data: { name: "World" }
|
|
26
37
|
# )
|
|
27
38
|
def render_to_file(path, **kwargs)
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
39
|
+
target = file_write { resolve_links(Pathname(path)) }
|
|
40
|
+
temp = open_temp_file(target)
|
|
41
|
+
begin
|
|
42
|
+
render.pdf_stream(**kwargs) { |chunk| file_write { temp.write(chunk) } }
|
|
43
|
+
file_write { move_into_place(temp, target) }
|
|
44
|
+
ensure
|
|
45
|
+
discard_temp_file(temp)
|
|
46
|
+
end
|
|
32
47
|
nil
|
|
33
|
-
rescue Errno::EACCES, Errno::ENOSPC, Errno::EISDIR, Errno::ENOENT, Errno::EROFS => e
|
|
34
|
-
raise PoliPage::InvalidOptionsError,
|
|
35
|
-
"failed to write to #{pathname}: #{e.class}: #{e.message}"
|
|
36
48
|
end
|
|
37
49
|
|
|
50
|
+
# Errors of the file system: `Errno::*` and Ruby's own `IOError`.
|
|
51
|
+
FILE_WRITE_ERRORS = [SystemCallError, ::IOError].freeze
|
|
52
|
+
private_constant :FILE_WRITE_ERRORS
|
|
53
|
+
|
|
38
54
|
private
|
|
39
55
|
|
|
40
|
-
def
|
|
41
|
-
|
|
42
|
-
|
|
56
|
+
def file_write
|
|
57
|
+
yield
|
|
58
|
+
rescue *FILE_WRITE_ERRORS => e
|
|
59
|
+
raise PoliPage::FileWriteError.new(cause: e)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Most links the file system follows in one path (Linux's limit).
|
|
63
|
+
MAX_LINKS = 40
|
|
64
|
+
private_constant :MAX_LINKS
|
|
65
|
+
|
|
66
|
+
# The file `path` names once its symbolic links are followed, so the
|
|
67
|
+
# rename replaces that file, not the link. An existing file is its real
|
|
68
|
+
# path. A link whose target does not exist yet resolves, hop by hop, to
|
|
69
|
+
# that target, which the rename then creates: each relative link is read
|
|
70
|
+
# against the real directory of the link, as the file system does, so a
|
|
71
|
+
# `..` after a linked directory leaves the directory the link points to.
|
|
72
|
+
def resolve_links(path)
|
|
73
|
+
return Pathname(File.realpath(path)) if path.exist?
|
|
74
|
+
|
|
75
|
+
current = path.to_s
|
|
76
|
+
MAX_LINKS.times do
|
|
77
|
+
return Pathname(current) unless File.symlink?(current)
|
|
78
|
+
|
|
79
|
+
link = File.readlink(current)
|
|
80
|
+
current = File.absolute_path?(link) ? link : File.join(File.realpath(File.dirname(current)), link)
|
|
81
|
+
end
|
|
82
|
+
raise Errno::ELOOP, path.to_s
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# The temporary file sits next to the target, so the final rename stays
|
|
86
|
+
# on one file system and replaces the target in one step.
|
|
87
|
+
#
|
|
88
|
+
# Two targets fail here, before any render, checked from the file's
|
|
89
|
+
# attributes without opening it: one that exists but is not a regular
|
|
90
|
+
# file (a directory, a FIFO, a device, a socket), which the rename would
|
|
91
|
+
# otherwise replace with a regular file, and one that cannot be written,
|
|
92
|
+
# as opening it for writing would fail (the rename could replace it even
|
|
93
|
+
# so). `target` is already resolved, so a link to a regular file passes.
|
|
94
|
+
def open_temp_file(target)
|
|
95
|
+
file_write do
|
|
96
|
+
raise ::IOError, "#{target}: not a regular file" if target.exist? && !target.file?
|
|
97
|
+
raise Errno::EACCES, target.to_s if target.exist? && !target.writable?
|
|
98
|
+
|
|
99
|
+
FileUtils.mkdir_p(target.dirname)
|
|
100
|
+
Tempfile.create([".#{target.basename}.", ".part"], target.dirname.to_s, binmode: true)
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def move_into_place(temp, target)
|
|
105
|
+
temp.close
|
|
106
|
+
File.chmod(target_mode(target), temp.path)
|
|
107
|
+
File.rename(temp.path, target.to_s)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# The mode the file would get from a plain `File.open(path, "wb")`: an
|
|
111
|
+
# existing file keeps its own, a new one gets the umask's default (the
|
|
112
|
+
# temporary file is created owner-only).
|
|
113
|
+
def target_mode(target)
|
|
114
|
+
target.file? ? target.stat.mode & 0o7777 : 0o666 & ~File.umask
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Removes the temporary file, if still there. A failure to close it must
|
|
118
|
+
# not hide the outcome of the call, so it is ignored.
|
|
119
|
+
def discard_temp_file(temp)
|
|
120
|
+
begin
|
|
121
|
+
temp.close unless temp.closed?
|
|
122
|
+
rescue *FILE_WRITE_ERRORS
|
|
123
|
+
nil
|
|
43
124
|
end
|
|
44
|
-
|
|
45
|
-
# Clean up the partial / empty file so the caller can see the failure
|
|
46
|
-
# cleanly. Mirrors Node `fs.createWriteStream` cleanup behaviour.
|
|
47
|
-
FileUtils.rm_f(pathname)
|
|
48
|
-
raise
|
|
125
|
+
FileUtils.rm_f(temp.path)
|
|
49
126
|
end
|
|
50
127
|
end
|
|
51
128
|
end
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module PoliPage
|
|
4
|
-
# Event payload for the `on_retry` constructor hook
|
|
4
|
+
# Event payload for the `on_retry` constructor hook.
|
|
5
5
|
#
|
|
6
6
|
# - `attempt` [Integer] 1-based — the attempt about to be made.
|
|
7
7
|
# - `delay_ms` [Integer] sleep duration in milliseconds before this
|
data/lib/poli_page/version.rb
CHANGED
data/sig/poli_page/errors.rbs
CHANGED
|
@@ -14,7 +14,7 @@ module PoliPage
|
|
|
14
14
|
def validation_error?: () -> bool
|
|
15
15
|
def network_error?: () -> bool
|
|
16
16
|
def retryable?: () -> bool
|
|
17
|
-
def to_payload: () -> { code: String, message: String, status: Integer?,
|
|
17
|
+
def to_payload: () -> { code: String, message: String, status: Integer?, requestId: String? }
|
|
18
18
|
def payload_status: () -> Integer?
|
|
19
19
|
end
|
|
20
20
|
|
|
@@ -54,7 +54,15 @@ module PoliPage
|
|
|
54
54
|
end
|
|
55
55
|
|
|
56
56
|
class DownloadError < Error
|
|
57
|
-
def initialize: (?status: Integer?, ?cause: Exception?, ?message: String?) -> void
|
|
57
|
+
def initialize: (?status: Integer?, ?cause: Exception?, ?message: String?, ?timeout: bool) -> void
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
class InvalidResponseError < Error
|
|
61
|
+
def initialize: (status: Integer, ?request_id: String?, ?cause: Exception?) -> void
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
class FileWriteError < Error
|
|
65
|
+
def initialize: (cause: Exception) -> void
|
|
58
66
|
end
|
|
59
67
|
|
|
60
68
|
class InternalError < Error
|