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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 0caa9c1105af62976da1ba1a5a6ef40ad34386359f1b43cea033976146054d6c
4
- data.tar.gz: ed1c09e7c54ab2ee34d9b6286a674c755ef6a1450b53569490be006939c3ae0d
3
+ metadata.gz: f6e6c0236a43dbf8ecc971781a63e99e6e878f045f4ebf9b9c86f589618f69fc
4
+ data.tar.gz: d4b0dc1776a2090b1dd57c5623a819048050f1188fcf8ad519a78d2c03ca481e
5
5
  SHA512:
6
- metadata.gz: 779392d70b3ca9aa042d5e935f5d24d9da8d4eede920f43dfb656907107f924672aa644744a0edceb3950538118d99ff3b8fd24ebef274fa2da27d400dc3b68d
7
- data.tar.gz: 0ddcc5b1c996fb19e51c60025a9353412a6f3f198e84d263a3385eff435d659e303828795efb5185067e2bf8383fb88218b56928e640f35d70cecc3046c38e06
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 directly to disk via
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"`; the per-gem RubyGems API key is
85
- scoped to `poli-page` only.
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
 
@@ -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 (sdk-ruby-plan.md §10.1 redaction rule).
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 a JSON object (not JSON, empty, or another
224
- # JSON value) raises `INTERNAL_ERROR`, not retried, through `on_error`
225
- # like any error the SDK is about to raise (sdk-specification.md §4.1).
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
- raise_terminal(PoliPage::InternalError.new("response body was not valid JSON: #{e.message}",
233
- status: response.status, cause: e))
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)
@@ -8,7 +8,7 @@ require_relative "models/thumbnail"
8
8
 
9
9
  module PoliPage
10
10
  module Resources
11
- # `client.documents` namespace (sdk-ruby-plan.md §13 Phase 4, port of
11
+ # `client.documents` namespace (port of
12
12
  # sdk-node/src/documents.ts).
13
13
  class Documents
14
14
  def initialize(client)
@@ -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`, and `DOWNLOAD_FAILED` whatever the storage
57
- # status (the presigned-URL download is never retried).
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) || @code == "DOWNLOAD_FAILED"
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
- # Canonical wire payload for framework integrations:
66
- # `{code:, message:, status:, request_id:}`. `status` surfaces 503 for
67
- # connection failures, 504 for timeouts, the API HTTP status for
68
- # status-bearing errors, and nil when unknown. The {#status} reader
69
- # itself stays nil for transport-error instances — only the payload
70
- # surfaces 503/504.
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
- request_id: @request_id
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}). Never retried.
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` (sdk-ruby-plan.md §13 Phase 1).
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 (sdk-ruby-plan.md §7).
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 S3 and MUST NOT be retried by the SDK
15
- # (sdk-specification.md §7.2, §9.1): the `Net::HTTP` it uses has
16
- # `max_retries` 0, so it never sends the GET twice either.
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 network message when no
20
- # complete answer came (a connection error, a dropped body or a timeout).
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. `@timeout` bounds each socket operation (connect, and
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 or network failure.
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
- response = http.start do
33
- http.request(Net::HTTP::Get.new(uri.request_uri)) do |res|
34
- guard_presigned_status!(res)
35
- res.read_body
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 or network failure and PoliPage::InternalError on
45
- # Content-Length: 0 (port of Node `render.ts:128-130` `!response.body`
46
- # guard). An error raised by the block itself propagates unchanged.
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
- in_block = false
51
- http.start do
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 do |chunk|
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
- nil
62
- rescue *Transport::NETWORK_ERRORS => e
63
- raise if in_block
100
+ end
64
101
 
65
- raise PoliPage::DownloadError.new(cause: e)
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
- private
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 (sdk-ruby-plan.md §3.2). Translates network-level exceptions into
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 (sdk-ruby-plan.md §13 Phase 1).
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-ruby-plan.md §5.4, sdk-specification.md §2). Pure, no runtime
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 (sdk-ruby-plan.md §3.4).
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
- # (sdk-ruby-plan.md §13 Phase 4; mirrors Node `documents.ts:75-77`).
7
+ # (mirrors Node `documents.ts:75-77`).
8
8
  DocumentPreviewResult = Data.define(:html, :page_count)
9
9
  end
@@ -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 straight to `path`. Built on
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. Overwrites
11
- # existing files. On render error the partial file is removed before
12
- # the error is re-raised.
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::InvalidOptionsError] on filesystem failure
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
- pathname = Pathname(path)
29
- FileUtils.mkdir_p(pathname.dirname) unless pathname.dirname.directory?
30
-
31
- stream_to_path(pathname, kwargs)
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 stream_to_path(pathname, kwargs)
41
- File.open(pathname, "wb") do |io|
42
- render.pdf_stream(**kwargs) { |chunk| io.write(chunk) }
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
- rescue StandardError
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 (sdk-ruby-plan.md §10.2).
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module PoliPage
4
- VERSION = "0.11.0"
4
+ VERSION = "0.13.0"
5
5
  end
@@ -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?, request_id: String? }
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
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: poli-page
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.11.0
4
+ version: 0.13.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Poli Page