end_point_blank 0.6.0 → 0.12.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.
Files changed (31) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +621 -0
  3. data/README.md +236 -19
  4. data/end_point_blank.gemspec +5 -3
  5. data/lib/end_point_blank/access_tokens.rb +244 -25
  6. data/lib/end_point_blank/authorization.rb +105 -20
  7. data/lib/end_point_blank/commands/authentication_cache.rb +141 -19
  8. data/lib/end_point_blank/commands/basic_authenticate.rb +66 -2
  9. data/lib/end_point_blank/commands/bearer_generate.rb +36 -0
  10. data/lib/end_point_blank/commands/endpoint_authorize.rb +46 -1
  11. data/lib/end_point_blank/commands/endpoint_update.rb +2 -2
  12. data/lib/end_point_blank/commands/generate_access_token.rb +241 -8
  13. data/lib/end_point_blank/commands/http.rb +20 -1
  14. data/lib/end_point_blank/configuration.rb +111 -4
  15. data/lib/end_point_blank/configuration_error.rb +18 -0
  16. data/lib/end_point_blank/rails/authenticated.rb +62 -7
  17. data/lib/end_point_blank/rails/authorized.rb +9 -13
  18. data/lib/end_point_blank/target_url.rb +57 -0
  19. data/lib/end_point_blank/token_unavailable_error.rb +102 -0
  20. data/lib/end_point_blank/unauthorized_error.rb +81 -1
  21. data/lib/end_point_blank/version.rb +1 -1
  22. data/lib/end_point_blank/writers/delayed_writer.rb +131 -21
  23. data/lib/end_point_blank/writers/direct_writer.rb +1 -1
  24. data/lib/end_point_blank/writers/exception_writer.rb +11 -2
  25. data/lib/end_point_blank/writers/log_writer.rb +1 -1
  26. data/lib/end_point_blank/writers/request_writer.rb +1 -0
  27. data/lib/end_point_blank/writers/response_writer.rb +1 -0
  28. data/lib/end_point_blank/writers/shared.rb +35 -4
  29. data/lib/end_point_blank.rb +248 -3
  30. metadata +15 -10
  31. data/lib/end_point_blank/loggers/logger.rb +0 -30
data/README.md CHANGED
@@ -8,8 +8,8 @@ auto-loads (railtie + middleware) when Rails is present.
8
8
  ## Capabilities
9
9
 
10
10
  - **Endpoint tracking** — every request/response passing through the Rack middleware is reported.
11
- - **Authorization** — outbound calls to other EndPointBlank-protected services are signed
12
- (`Basic` client-credential or cached `Bearer` token), and inbound requests can be authorized
11
+ - **Authorization** — outbound calls to other EndPointBlank-protected services carry a
12
+ `Bearer` access token (never this service's own client credentials), and inbound requests can be authorized
13
13
  against the EndPointBlank service before your action runs.
14
14
  - **Error, request, response, and log reporting** — background, queued, non-blocking delivery to
15
15
  the EndPointBlank intake API.
@@ -61,29 +61,51 @@ EndPointBlank::Writers::LogWriter.info("service started", { pid: Process.pid })
61
61
 
62
62
  ## Configuration
63
63
 
64
- `EndPointBlank.configure { |c| ... }` yields the `EndPointBlank::Configuration` singleton.
64
+ `EndPointBlank.configure { |c| ... }` yields the `EndPointBlank::Configuration` settings and
65
+ applies every assignment made inside the block together, only once the block returns without
66
+ raising -- a block that raises leaves the configuration exactly as it was before the call.
65
67
  Every setting listed below can be set explicitly in that block, and most also fall back to an
66
68
  `ENDPOINTBLANK_*` environment variable, then to a built-in default.
67
69
 
70
+ `c` is only valid for the duration of the block: once `configure` returns, whether the block
71
+ returned normally or raised, `c` is frozen, and every String, Array or Hash value it holds is
72
+ first replaced with its own frozen deep copy. A write made through a reference to `c` kept past
73
+ the block always raises `FrozenError` -- a reassignment (`saved.app_name = "x"`) because `c`
74
+ itself is frozen, and an in-place edit (`saved.masking_rules << rule`, `saved.app_name << "x"`,
75
+ editing a rule Hash in place) because the value it points to is frozen too, not just `c`. A read
76
+ that bypasses `c` -- `EndPointBlank::Configuration.instance.app_name`, or `EndPointBlank.logger`
77
+ right after `c.logger = ...` earlier in the same block -- still sees the value from before the
78
+ `configure` call started, not what the block has set on `c` so far, until the block returns and
79
+ the change is applied.
80
+
81
+ Assigning a String, Array or Hash through `c` copies it rather than storing the object itself:
82
+ after `c.masking_rules = rules`, mutating the `rules` array you passed in no longer affects the
83
+ live configuration -- call `configure` again to apply a further change. `logger`, `mask_hook`
84
+ and `version_finder` are not String/Array/Hash, so they are held by reference like any other
85
+ object the caller hands in: mutating one through a retained `c` (`saved.logger.level = ...`)
86
+ still reaches the live value, the same as mutating it through `EndPointBlank.logger` or
87
+ `Configuration.instance.logger` directly would.
88
+
68
89
  **Precedence: explicit `configure` value > `ENDPOINTBLANK_*` environment variable > default.**
69
90
 
70
91
  | `configure` setting | Env var fallback | Default | Notes |
71
92
  |---|---|---|---|
72
- | `client_id` | `ENDPOINTBLANK_CLIENT_ID` | `nil` | Used to build the `Basic` authorization header. |
93
+ | `client_id` | `ENDPOINTBLANK_CLIENT_ID` | `nil` | Authenticates this service to its own intake (`Basic`). Never sent to a provider. |
73
94
  | `client_secret` | `ENDPOINTBLANK_CLIENT_SECRET` | `nil` | Paired with `client_id`. |
74
95
  | `base_url` | `ENDPOINTBLANK_BASE_URL` | `https://in.endpointblank.com` | Base for access-token, authorize, and endpoint-update APIs. |
75
96
  | `log_base_url` | `ENDPOINTBLANK_LOG_BASE_URL` | `https://log.endpointblank.com` | Base for error/request/response/log reporting APIs. |
76
97
  | `app_name` | `ENDPOINTBLANK_APP_NAME` | `Rails.application.name.underscore` if Rails is defined, else `nil` | Identifies your app to EndPointBlank. |
77
98
  | `env_name` | `ENDPOINTBLANK_ENV` | `RACK_ENV`, then `APP_ENV`, then `Rails.env` if defined, else `"production"` (resolved per-request by `SessionConfiguration.env_name`, not read directly off `Configuration`) | The environment name reported with each request/response payload. |
78
99
  | `logger` | — | A `::Logger.new($stdout, level: ::Logger::INFO)`, or `Rails.logger` under Rails (set by the railtie) | Any object with `.debug`/`.info`/`.warn`/`.error`/`.fatal` works. |
79
- | `worker_count` | — | `4` | Currently unused by the delayed writer (which always spins up 2 threads); reserved. |
100
+ | `worker_count` | — | `4` | Number of background threads draining the delayed writer's queue. Falls back to 2 when set to `nil`. |
80
101
  | `token_ttl` | — | `nil` | Optional TTL (seconds) requested when generating a `Bearer` access token. |
81
- | `cache_ttl` | — | `300` | TTL (seconds) for the authorization decision cache. |
102
+ | `cache_ttl` | — | `300` | TTL, in whole seconds, for the authorization decision cache. **Omit it to get the 300-second default.** `0` disables the cache; any positive `Integer` is that many seconds. Anything else — an explicit `nil`, a negative number, or a non-`Integer` such as `"300"` or `3.5` — raises `ArgumentError` from the `c.cache_ttl = ...` assignment itself, at configure time, and leaves the previous value in place, so a bad value stops your app at boot instead of surfacing on the first authorized request. The cache is a plain in-memory Hash scoped to this process (no `Rails.cache`, Redis, or other shared store), so a Puma or Unicorn worker and every separate app instance each hold their own cache and their own view of `cache_ttl`. Changing `cache_ttl` at runtime takes effect immediately for already-cached entries, not just new ones: an entry is valid only while it is within *both* its original write-time expiry and the *currently configured* `cache_ttl` measured from when it was written, so raising `cache_ttl` never extends an entry already in the cache, and lowering it shortens one on its next read. `cache_ttl = 0` disables the cache — the moment a read or a store in a given process observes it disabled, *that process's* entire cache is cleared, not just the entry being looked up or written, and a store made while disabled inserts nothing there. This is per process, not fleet-wide: each worker or instance clears only its own cache, and only once it has itself observed `cache_ttl` disabled and then handled an `Authorized` request (or a direct cache call) while disabled — a worker that is idle, or hasn't yet picked up the new config, keeps serving whatever it already cached until it does. (A disable followed by a re-enable with no cache read or store in between flushes nothing, since nothing observed the disabled state.) |
82
103
  | `trust_proxy_headers` | — | `true` | Whether the per-request `scheme`/`host`/`port` report honors `X-Forwarded-Proto`/`-Host`/`-Port`. See [Reported base URL](#reported-base-url). |
83
104
  | `masking_rules` | — | `[]` | Ordered list of masking rule hashes — see [Data masking](#data-masking). |
84
105
  | `mask_hook` | — | `nil` | Optional `->(payload, record_type_string) { payload }` run after `masking_rules`. |
85
106
  | `version_finder` | — | `nil` | Optional `->(request) { "1" }` overriding `EndPointBlank::Commands::VersionFinder`'s default header/param/path detection. |
86
107
  | `application_version` | — | `nil` | Reserved for reporting your app's own version. |
108
+ | `derive_base_url_from_client_id` | — | `false` | Derive the intake hostname from a slug-prefixed `client_id` when no `base_url` is set. See [Intake hostname from `client_id`](#intake-hostname-from-client_id). Only `true` or `false`; anything else raises `ArgumentError` at configure time. |
87
109
 
88
110
  Note: there is also a bare `environment` accessor on `Configuration`, but it is not read by any
89
111
  code path in this gem (the real per-request environment name is `env_name`, described above) — do
@@ -118,6 +140,42 @@ hostname on an internal port. `host` is caller-controlled either way (it has alw
118
140
  the `Host` header), and none of these three values is ever used as an identity or
119
141
  authorization key, so the worst case is a wrong *suggestion* that an admin has to approve.
120
142
 
143
+ ### Intake hostname from `client_id`
144
+
145
+ Each organization's intake will answer at its own hostname,
146
+ `https://<slug>.in.endpointblank.com`, and every new `client_id` starts with
147
+ that slug and a dot (`acima-x7k2mq.ijXI+MVwmrC5xH/9ZuGiQlAbAyobTqMa`). With
148
+ `c.derive_base_url_from_client_id = true`, the gem picks its intake in this
149
+ order:
150
+
151
+ 1. `base_url`, or else `ENDPOINTBLANK_BASE_URL`, if either is set;
152
+ 2. else, if the `client_id` carries a slug prefix,
153
+ `https://<slug>.in.endpointblank.com`;
154
+ 3. else `https://in.endpointblank.com`.
155
+
156
+ A `client_id` carries a slug prefix only when the part before its first `.`
157
+ has the exact shape of an organization slug and something follows the dot
158
+ (`EndPointBlank::Configuration.client_id_slug`). A credential issued before
159
+ slugs, including one with a `.` in it such as `my.client`, keeps calling
160
+ `https://in.endpointblank.com`.
161
+
162
+ **This is off by default, and turns on by default in a later release, once
163
+ DNS and TLS for `*.in.endpointblank.com` are live.** Until then those
164
+ hostnames do not resolve in production, so leave it off unless EndPointBlank
165
+ has told you otherwise. With it off, the base URL is `base_url`, else
166
+ `ENDPOINTBLANK_BASE_URL`, else `https://in.endpointblank.com`, whatever the
167
+ `client_id`.
168
+
169
+ The logs hostname is not derived: `log_base_url`, else
170
+ `ENDPOINTBLANK_LOG_BASE_URL`, else `https://log.endpointblank.com`, as before.
171
+
172
+ Every call to intake also sends `x-epb-sdk: ruby/<version>`, so
173
+ EndPointBlank can tell which SDK versions use a credential before it moves an
174
+ organization to another intake. The minimum Ruby version for a move is the
175
+ release that turns `derive_base_url_from_client_id` on by default, **not**
176
+ this one: with the option at its default here, the gem keeps calling
177
+ `https://in.endpointblank.com` after its organization has moved.
178
+
121
179
  ### `configure` block example
122
180
 
123
181
  ```ruby
@@ -150,27 +208,146 @@ export ENDPOINTBLANK_ENV=staging
150
208
 
151
209
  ### Authorization
152
210
 
153
- `EndPointBlank::Authorization.header(base_url = nil)` builds the outbound `Authorization` header
154
- used by the gem's own HTTP calls: a cached `Bearer` token covering `base_url` when one is
155
- available (via `EndPointBlank::AccessTokens`), otherwise `Basic` credentials built from
156
- `client_id` / `client_secret` -- which covers both giving no target and a token that could not
157
- be obtained.
211
+ `EndPointBlank::Authorization.header(base_url)` builds the `Authorization` header for an outbound
212
+ call to a provider. It is always a `Bearer` token covering `base_url` (via
213
+ `EndPointBlank::AccessTokens`, minting one if none is cached). It **never** falls back to `Basic`:
214
+ a client must never send its own `client_id` / `client_secret` to a provider or to the provider's
215
+ intake. When no token can be obtained it raises `EndPointBlank::TokenUnavailableError` instead.
158
216
 
159
217
  ```ruby
160
- EndPointBlank::Authorization.header # => "Basic ..."
218
+ # Pass the URL you are about to call, NOT a hostname. Its userinfo, query and
219
+ # fragment are removed before the token request; they are never sent to
220
+ # intake, logged, or kept on the error.
221
+ url = "https://api.example.com/orders"
222
+
223
+ begin
224
+ auth = EndPointBlank::Authorization.header(url) # => "Bearer ..."
225
+ Excon.post(url, headers: { "Authorization" => auth }, body: payload)
226
+ rescue EndPointBlank::TokenUnavailableError => e
227
+ # No token, so the provider was never called. e.outcome / e.status /
228
+ # e.failure say why (see "Why a token could not be minted" below): retry,
229
+ # degrade, or fail your own request -- but do not send credentials instead.
230
+ Rails.logger.warn(e.message)
231
+ raise
232
+ end
233
+ ```
234
+
235
+ `base_url` is required. Until this release `header` with no argument returned `Basic` credentials; that
236
+ form is gone, and `header(nil)` or `header("")` raises `ArgumentError`, as does a URL that cannot be
237
+ parsed into an http or https URL with a host (nothing is sent, and the message does not repeat the URL). The SDK's own calls to its
238
+ own intake (authorize, token minting, endpoint updates, the log/request/response writers) still
239
+ authenticate with `Basic`, which is safe because intake already holds this service's credential;
240
+ they use the internal `EndPointBlank::Authorization.intake_header`, which is not for outbound
241
+ calls.
161
242
 
162
- # Pass the URL you are about to call, NOT a hostname.
163
- # Strip any query string or fragment first -- intake rejects both.
164
- EndPointBlank::Authorization.header("https://api.example.com/orders") # => "Bearer ..." if a token is cached
243
+ `TokenUnavailableError` (a subclass of `EndPointBlank::Error`) carries `base_url` (the URL with its
244
+ userinfo, query and fragment removed; the raw value is never kept), `failure` (the
245
+ `EndPointBlank::AccessTokens::Failure` recorded for the mint, or `nil`), and the shortcuts
246
+ `outcome` and `status`. Its message names the stripped URL and a fixed reason for the outcome
247
+ -- never intake's response body, which stays on `failure.reason` -- for example:
248
+
249
+ ```
250
+ Could not mint an EndPointBlank access token for https://api.example.com/orders:
251
+ intake rejected this application's client credential (HTTP 401); retrying cannot help --
252
+ re-issue the credential. EndPointBlank never sends this service's client_id/client_secret to a
253
+ provider, so there is no Basic-auth fallback and the call must not be made without a token.
165
254
  ```
166
255
 
256
+ | `outcome` | Reason in the message |
257
+ |---|---|
258
+ | `:credential_rejected` | `intake rejected this application's client credential (HTTP 401); retrying cannot help -- re-issue the credential` |
259
+ | `:request_rejected` | `intake refused the token request (HTTP <status>); check the URL and that a grant covers the target` |
260
+ | `:server_error` | `intake failed to issue a token (HTTP <status>); this may be transient` |
261
+ | `:transport_error` | `intake could not be reached (timeout, connection refused or retries exhausted); this may be transient` |
262
+ | `:transport_error`, the mint raised | `the token request failed unexpectedly` |
263
+ | none recorded | `the token request failed for an unknown reason` |
264
+
265
+ ` (HTTP <status>)` is left out when there is no status.
266
+
267
+ A mint that raises rather than reporting a failure -- a bug, not intake being unreachable -- is
268
+ reported as this error too, with outcome `:transport_error`, `unexpected?` true and the exception
269
+ as `cause`; its message is not copied into the error's. Only a missing credential's
270
+ `ConfigurationError` escapes `header` as itself.
271
+
272
+ The SDK's own calls to its intake raise `EndPointBlank::ConfigurationError` (also a subclass of
273
+ `EndPointBlank::Error`) when `client_id` or `client_secret` is missing or empty, rather than
274
+ sending an empty `Basic` credential.
275
+
167
276
  The argument is the URL you are about to call. intake matches it against registered base URLs by
168
277
  longest path prefix, so you need not know how the target registered itself -- `header` for
169
278
  `https://api.example.com/orders/42` reuses a token already cached for
170
279
  `https://api.example.com/orders`. `EndPointBlank::AccessTokens` caches one token per base URL
171
280
  intake resolves to, not one per process, so a service that calls several targets holds a token
172
- for each. A URL that does not match character-for-character (a different case, a query string,
173
- an unregistered path) simply misses and mints a new token -- it never guesses.
281
+ for each. The lookup uses the URL with its userinfo, query and fragment removed and its scheme and
282
+ host lowercased, as intake does; beyond that, a URL that does not match character-for-character
283
+ (a different path case, an unregistered path) simply misses and mints a new token -- it never guesses.
284
+
285
+ ### Why a token could not be minted
286
+
287
+ `EndPointBlank::AccessTokens.token` answers with a token String or `nil` (or raises, as itself,
288
+ anything the mint raised that is not a transport error -- only `Authorization.header` wraps that),
289
+ which is all most callers need. When `nil` is not enough — when you want to know whether retrying could possibly
290
+ help — call `token_result` instead, which answers with the token or the `Failure` for that call:
291
+
292
+ ```ruby
293
+ url = "https://api.example.com/orders"
294
+ result = EndPointBlank::AccessTokens.token_result(url)
295
+
296
+ if result.is_a?(EndPointBlank::AccessTokens::Failure)
297
+ failure = result
298
+
299
+ case failure.outcome
300
+ when :credential_rejected
301
+ # intake answered 401. Permanent until the credential itself changes:
302
+ # re-issue it in the portal and update client_id / client_secret.
303
+ raise "EndPointBlank credential rejected (#{failure.reason})"
304
+ when :request_rejected
305
+ # A 400 or 422: intake could not resolve the target or source
306
+ # application, or the request itself was malformed. Retrying will not fix
307
+ # it, but the credential is fine.
308
+ when :server_error, :transport_error
309
+ # A 5xx, an unusable response, a timeout, a refused connection. Try again.
310
+ end
311
+ end
312
+ ```
313
+
314
+ `EndPointBlank::AccessTokens.last_failure(url)` still answers the last failure recorded for a
315
+ URL, and returns `nil` once a mint for that URL succeeds again, so it never reports a problem
316
+ that has already cleared. It is a shared slot, though: read after `token` returned `nil`, another
317
+ thread may already have cleared or replaced it. When you need the reason for your own call, use
318
+ `token_result`, whose `Failure` is captured inside the cache's lock for that call. A `Failure` carries `base_url`, `outcome`, `status` (the HTTP
319
+ status, or `nil` when no usable one was obtained), `reason`, and `at`, and answers
320
+ `#credential_rejected?`, `#request_rejected?`, `#server_error?` and `#transport_error?`.
321
+
322
+ `token`, `token_result`, `exists?` and `last_failure` all remove the URL's userinfo, query and
323
+ fragment first, so the cache, the failure record (and its `base_url`) and the log lines only ever
324
+ hold the stripped URL. A URL that cannot be parsed into an http or https URL with a host is never sent:
325
+ `token_result` answers a `:request_rejected` `Failure` with no `status` and no `base_url`, and
326
+ nothing is recorded.
327
+
328
+ There is deliberately no `#retriable?` or other single retry/no-retry boolean. Retrying a `400`
329
+ or a `422` is exactly as futile as retrying a `401` — intake answers `400` for an invalid
330
+ `token_ttl` or a missing `base_url`, and `422` when the target or source application cannot be
331
+ resolved — so a boolean would have to answer for cases whose only honest answer is "it depends
332
+ what you are going to do about it". Branch on the outcome instead.
333
+
334
+ Classification is on the HTTP status first and the body second. A `401` whose body will not parse
335
+ is still `:credential_rejected`: the SDK reaches intake through a proxy, and a WAF or load
336
+ balancer can answer 401 with an HTML page intake never generated. `:transport_error` means one
337
+ thing only — no usable HTTP status was obtained because the request never completed (an Excon,
338
+ socket, SSL or timeout error). Anything else raised while minting is not a transport error and
339
+ propagates.
340
+
341
+ The body decides exactly one thing, and only on a 2xx: whether a token was actually minted. A
342
+ success means a token is there to read — the body parsed and carries a non-empty `token` and the
343
+ non-empty `base_url` to cache it under. A 2xx that will not parse, or carries no token, or a
344
+ token with no `base_url`, is a `:server_error` keeping its real 2xx status, because intake's
345
+ `base_url` is `NOT NULL` and it answers a 4xx rather than minting when the URL resolves to
346
+ nothing — so a 2xx missing one is a broken server, not a refused request.
347
+
348
+ The same verdict is available one level down, without the cache, from
349
+ `EndPointBlank::Commands::GenerateAccessToken.token_result(base_url)`, which returns an
350
+ `AccessTokenResult` with `outcome`, `status`, `payload` and the same predicates.
174
351
 
175
352
  Under Rails, protect an inbound endpoint by including the `Authorized` concern in a controller —
176
353
  it calls `EndPointBlank::Commands::EndpointAuthorize.authorize(request)` before the action, and
@@ -197,6 +374,32 @@ preserve it) and you registered the external hostname in the portal, either upda
197
374
  registered hostname to the internal one the app now reports, or configure the proxy to preserve
198
375
  `Host`. Deployments where `Host` and `X-Forwarded-Host` agree are unaffected.
199
376
 
377
+ The `Authenticated` concern is the lighter sibling: it calls
378
+ `EndPointBlank::Commands::BasicAuthenticate.authenticate(request)` before the action and refuses
379
+ the same way, but records nothing on the Rack env, sets no deprecation headers and — matching
380
+ every other SDK's authenticate path — does not cache intake's answer.
381
+
382
+ ```ruby
383
+ class OrdersController < ApplicationController
384
+ include EndPointBlank::Rails::Authenticated
385
+ end
386
+ ```
387
+
388
+ `EndPointBlank::UnauthorizedError#status` is intake's own verdict, from either concern, so
389
+ `rescue_from` can render it directly. 401 and 403 are different remedies and must not be
390
+ collapsed:
391
+
392
+ | intake answered | `error.status` |
393
+ | --- | --- |
394
+ | 401 | `401` — re-check or re-issue the credential |
395
+ | 403 | `403` — ask for a grant covering this endpoint |
396
+ | any other non-201 | that status, verbatim |
397
+ | nothing at all | `503` — the check could not be made, so nothing judged this caller |
398
+
399
+ `UnauthorizedError.new(message)` still defaults to 401; the status is an optional second
400
+ argument. Raise the instance (`raise UnauthorizedError.new(msg, status)`) rather than
401
+ `raise UnauthorizedError, msg` — the two-argument form cannot carry a status.
402
+
200
403
  ### Error reporting
201
404
 
202
405
  Exceptions raised while `EndPointBlank::Middleware::Rack::ReportInteraction` is on the stack are
@@ -226,8 +429,21 @@ EndPointBlank::Writers::LogWriter.fatal("out of workers")
226
429
 
227
430
  All writers (`RequestWriter`, `ResponseWriter`, `ExceptionWriter`, `LogWriter`) enqueue their
228
431
  payload onto a bounded, in-memory queue (`DelayedWriter`, capacity 1000, drop-oldest under
229
- sustained backpressure) drained by two background threads that POST batches via `excon`. Delivery
230
- is fire-and-forget and never raises into your request cycle.
432
+ sustained backpressure) drained by `worker_count` background threads that POST batches via `excon`,
433
+ six payloads per request. Batches are cut by position, so two identical payloads are two payloads.
434
+ Delivery is fire-and-forget and never raises into your request cycle.
435
+
436
+ A worker thread does not die. A batch can be lost — the intake may be unreachable, or the send path
437
+ may raise something nobody anticipated — but the loop catches every `StandardError`, logs it at
438
+ `error` level through `EndPointBlank.logger` with a running count of consecutive failures, backs off
439
+ (0.1s, doubling, capped at 30s), and keeps draining; the count resets on the first clean pass. Only
440
+ an error outside `StandardError` — `SystemExit`, `Interrupt`, `SignalException`, `NoMemoryError`,
441
+ i.e. the process itself going down — ends a worker.
442
+
443
+ A writer may optionally define `on_success(response)` and `on_failure(response)` to hear about each
444
+ batch. `on_failure` receives `nil` when the intake never answered at all: `Commands::Http` returns
445
+ `nil` once its three attempts are exhausted, which is the absence of a status rather than a failing
446
+ one. A writer that defines neither is unaffected.
231
447
 
232
448
  ### Data masking
233
449
 
@@ -290,6 +506,7 @@ end
290
506
 
291
507
  class OrdersController < ApplicationController
292
508
  include EndPointBlank::Rails::Authorized # authorize inbound requests before each action
509
+ # or: include EndPointBlank::Rails::Authenticated # authenticate only, no caching
293
510
  include EndPointBlank::Rails::Versioned
294
511
 
295
512
  version ["v1", "v2"], only: [:index]
@@ -8,9 +8,9 @@ Gem::Specification.new do |spec|
8
8
  spec.authors = ["Robert A. Lasch"]
9
9
  spec.email = ["rlasch@gmail.com"]
10
10
 
11
- spec.summary = "Ruby/Rails client for EndPointBlank — endpoint tracking, authorization, and error/request/response/log reporting."
12
- spec.description = "EndPointBlank client library for Ruby. A framework-agnostic core runs in plain Ruby / Sinatra, with Rails supported as an auto-loaded adapter. Provides API endpoint tracking, authorization, and error/request/response/log reporting."
13
- spec.homepage = "https://github.com/EndPointBlank/end_point_blank_rails"
11
+ spec.summary = "Ruby and Rails SDK for EndPointBlank: authorize service-to-service API calls, report endpoint versions, and see which clients still call deprecated API versions."
12
+ spec.description = "Ruby and Rails SDK for EndPointBlank: authorize service-to-service (machine-to-machine) API calls, report endpoint versions, and see which clients still call deprecated API versions before you sunset them. A framework-agnostic core runs in plain Ruby / Sinatra, with Rails supported as an auto-loaded adapter."
13
+ spec.homepage = "https://endpointblank.com"
14
14
  spec.required_ruby_version = ">= 3.4.2"
15
15
  spec.license = "Nonstandard"
16
16
 
@@ -18,6 +18,8 @@ Gem::Specification.new do |spec|
18
18
 
19
19
  spec.metadata["homepage_uri"] = spec.homepage
20
20
  spec.metadata["source_code_uri"] = "https://github.com/EndPointBlank/end_point_blank_rails"
21
+ spec.metadata["documentation_uri"] = "https://endpointblank.com/docs/sdk-setup"
22
+ spec.metadata["bug_tracker_uri"] = "https://github.com/EndPointBlank/end_point_blank_rails/issues"
21
23
  spec.metadata["changelog_uri"] = "https://github.com/EndPointBlank/end_point_blank_rails/releases"
22
24
 
23
25
  # Specify which files should be added to the gem when it is released.