atlas_rb 1.27.0 → 1.27.2

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: 40ea8cbef3fa493fefcf29c0dd15a4d7edf72efb58134f8d21050421517ef05f
4
- data.tar.gz: 36a4eb18338f27da7ee744233e7b54fd6eb20faa76201e907f94703b8c6583e2
3
+ metadata.gz: 3bf07fbfa75e57be6651cc352fc2216f8c5e4029a68393018fa14b8e6a34e6aa
4
+ data.tar.gz: 63d66617e658bc0693c7a07afdf0b38cac2067f464b3ba1e0466b3a8d7d1860e
5
5
  SHA512:
6
- metadata.gz: d33812411e71cfa8bf0031fae4f81b735d598b75cb31f099e0c67a31d8dc0e4cc9c193524443f8dce983f87f96add5c9cc82c6de8c3d1d4211e69611254d4cc6
7
- data.tar.gz: f287d655a86594da49f54caa8cb505277577b3b81284e313cf956ee6424fd09f3bdf0085ae51fae14420468c386626eba3c300be3f41e5762d57c65de5938213
6
+ metadata.gz: 2071b3face694f62a03db93362462bf77381cfdcee77c138ff5a2baabe47f000d94ede673ca1d28f160d0348506a844346f920b71457669bae326769aa9286e3
7
+ data.tar.gz: 823ca671c648afad8c4a82111d658a691c6bf757e02cb942594c0a1c6afec2a1b6fda628c954ca909497c7168a6384f845fcf4ed229a1f470d5fabb1bac212c8
data/.version CHANGED
@@ -1 +1 @@
1
- 1.27.0
1
+ 1.27.2
data/CHANGELOG.md CHANGED
@@ -1,5 +1,38 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.27.2
4
+
5
+ Requires an Atlas that serves `GET /metrics/latency`.
6
+
7
+ ### Added — `AtlasRb::Metrics.latency`
8
+
9
+ Reads Atlas's own request latency for a window of up to 120 minutes: calls,
10
+ 5xx count, mean and approximate p50/p95/p99, and the slowest actions. Sent
11
+ with the acting NUID's signed assertion, which must name an admin. A
12
+ 5-second deadline, no retry and a fresh socket per call. Raises
13
+ `BackendUnavailableError` when Atlas's Redis is down and `ResourceError` on
14
+ any other non-200.
15
+
16
+ ## 1.27.1
17
+
18
+ Requires an Atlas that serves `GET /health` and the `backend_unavailable`
19
+ envelope.
20
+
21
+ ### Added — `AtlasRb::Health.read`
22
+
23
+ Reads Atlas's readiness report (`GET /health`). Returns the parsed body on a
24
+ `200` or a `503`, because a `503` there is an answer. Raises only on
25
+ transport failure, or `ResourceError` when the answer is not the report. No
26
+ credential, a 5-second deadline, no retry, and a fresh socket per call.
27
+
28
+ ### Added — `AtlasRb::BackendUnavailableError`
29
+
30
+ Raised on every binding when Atlas answers `503` with
31
+ `error: "backend_unavailable"`. Carries `dependency` (`"database"`,
32
+ `"index"` or `"storage"`) and `retry_after`. A sibling of
33
+ `ReadOnlyModeError`, not a `ResourceError`. A bodyless `503` from a proxy
34
+ still passes through.
35
+
3
36
  ## 1.27.0
4
37
 
5
38
  ### Removed — `Resource.class_for` and `Resource::TYPE_MAP`
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- atlas_rb (1.27.0)
4
+ atlas_rb (1.27.2)
5
5
  faraday (~> 2.7)
6
6
  faraday-follow_redirects (~> 0.3.0)
7
7
  faraday-multipart (~> 1)
data/README.md CHANGED
@@ -819,6 +819,68 @@ A `503` from a reverse proxy while Atlas restarts carries no JSON body and is
819
819
  a different condition with a different remedy, so it passes through as a plain
820
820
  response rather than raising.
821
821
 
822
+ ### Health and outages
823
+
824
+ `AtlasRb::Health.read` returns Atlas's readiness report. It sends no
825
+ credential, because Atlas serves `GET /health` unauthenticated:
826
+
827
+ ```ruby
828
+ report = AtlasRb::Health.read
829
+ report["status"] # => "ok" / "degraded" / "unavailable"
830
+ report["database"] # => "ok" / "unavailable"
831
+ report["index"] # => "ok" / "unavailable"
832
+ report["storage"] # => "ok" / "unavailable"
833
+ report["cache"] # => "ok" / "unavailable" / "disabled"
834
+ ```
835
+
836
+ Atlas answers `503` when the database, the index or the storage is down. That
837
+ is an answer, so `read` returns it like a `200`. It raises
838
+ `Faraday::ConnectionFailed` or `Faraday::TimeoutError` only when nothing
839
+ answers, and `AtlasRb::ResourceError` when something answers that is not the
840
+ report. It has a 5-second deadline (`read(timeout: 3)` to change it) and no
841
+ retry, so a probe answers inside its poller's own deadline.
842
+
843
+ When an ordinary call meets a dependency outage, Atlas answers `503` with
844
+ `error: "backend_unavailable"`, and every binding raises:
845
+
846
+ ```ruby
847
+ begin
848
+ AtlasRb::Work.find("w-789")
849
+ rescue AtlasRb::BackendUnavailableError => e
850
+ e.dependency # => "database" / "index" / "storage"
851
+ e.retry_after # => 30
852
+ end
853
+ ```
854
+
855
+ It is not a `ResourceError`, which hosts treat as a refusal of the request.
856
+
857
+ A streaming download (`Blob.content`, `Blob.version_content`) cannot raise
858
+ this: its body has already gone to your block. Check the returned `status`.
859
+
860
+ ### Atlas's own latency
861
+
862
+ `AtlasRb::Metrics.latency` reports how long Atlas took to serve its own
863
+ requests over a recent window. Atlas gates it to admins and `:system`, so
864
+ `nuid` must name an admin:
865
+
866
+ ```ruby
867
+ latency = AtlasRb::Metrics.latency(minutes: 60, nuid: current_user.nuid)
868
+ latency["calls"] # => 18402
869
+ latency["server_errors"] # => 3
870
+ latency["p95_ms"] # => 98
871
+ latency["slowest_actions"].first # => { "action" => "works#mods", "calls" => 912, "p95_ms" => 188 }
872
+ ```
873
+
874
+ Atlas times each request from when Rails dispatches it until it returns, so
875
+ the figures leave out the network and your own waiting. If you also time your
876
+ Atlas calls, the difference is time spent on your side. The percentiles are
877
+ interpolated from histogram buckets, so they are approximate.
878
+
879
+ Like `Health.read`, it has a 5-second deadline and no retry. It raises
880
+ `BackendUnavailableError` (with `dependency == "cache"`) when Atlas's Redis is
881
+ down, `ResourceError` on any other non-200 such as a `403`, and `Faraday`
882
+ errors when nothing answers.
883
+
822
884
  ## End-to-end example
823
885
 
824
886
  JSON responses come back as `AtlasRb::Mash` (a `Hashie::Mash` subclass), so
@@ -420,6 +420,47 @@ module AtlasRb
420
420
  end
421
421
  end
422
422
 
423
+ # Raised when Atlas cannot reach one of its own dependencies: an HTTP `503`
424
+ # whose body carries `error: "backend_unavailable"` and a `dependency` key
425
+ # naming `"database"`, `"index"` or `"storage"`.
426
+ #
427
+ # A sibling of {ReadOnlyModeError}, and deliberately NOT a {ResourceError}.
428
+ # Hosts commonly treat a `ResourceError` as a refusal of the request (Cerberus's
429
+ # `Authorizable` renders it as one), and an outage is no statement about the
430
+ # caller or the resource. Without this the outage reaches the host as the
431
+ # generic `ResourceError` a `5xx` read raises, indistinguishable from a bug.
432
+ #
433
+ # rescue AtlasRb::BackendUnavailableError => e
434
+ # render "outage", status: :service_unavailable
435
+ # response.headers["Retry-After"] = e.retry_after.to_s if e.retry_after
436
+ #
437
+ # A bodyless `503` from a proxy is not this; it keeps reaching the caller as
438
+ # it did before. Poll {AtlasRb::Health.read} to see every dependency at once.
439
+ class BackendUnavailableError < Error
440
+ # @return [String, nil] the envelope's `error` discriminator
441
+ # (`"backend_unavailable"`).
442
+ attr_reader :code
443
+
444
+ # @return [String, nil] which dependency failed: `"database"`, `"index"` or
445
+ # `"storage"`.
446
+ attr_reader :dependency
447
+
448
+ # @return [Integer, nil] seconds to wait before retrying, from Atlas's
449
+ # `Retry-After` response header.
450
+ attr_reader :retry_after
451
+
452
+ # @param message [String, nil] human-readable description.
453
+ # @param code [String, nil] the envelope's `error` discriminator.
454
+ # @param dependency [String, nil] the dependency that failed.
455
+ # @param retry_after [Integer, nil] seconds from the `Retry-After` header.
456
+ def initialize(message=nil, code: nil, dependency: nil, retry_after: nil)
457
+ @code = code
458
+ @dependency = dependency
459
+ @retry_after = retry_after
460
+ super(message || "Atlas cannot reach its #{dependency || "backend"}")
461
+ end
462
+ end
463
+
423
464
  # Raised when the transport has no way to authenticate a relay request:
424
465
  # neither `ATLAS_JWT` (BYO-JWT mode) nor a signing key
425
466
  # ({AtlasRb.config#assertion_signing_key}, relay-signing mode) is configured.
@@ -408,11 +408,16 @@ module AtlasRb
408
408
  # above: a maintenance window refuses writes on every path and a `503`
409
409
  # reaches neither of them. On all three shapes, because a write that
410
410
  # slips past it silently unwraps nil and reports success.
411
+ # - {Middleware::RaiseOnBackendUnavailable} — path-independent for the same
412
+ # reason: a dependency outage can answer any request on any shape. It
413
+ # must run before the read guard, or an outage on a read becomes the
414
+ # generic {AtlasRb::ResourceError} a host treats as a refusal.
411
415
  def error_middleware(builder, reads:)
412
416
  builder.use AtlasRb::Middleware::RaiseOnReadError if reads
413
417
  builder.use AtlasRb::Middleware::RaiseOnStaleResource
414
418
  builder.use AtlasRb::Middleware::RaiseOnResourceError
415
419
  builder.use AtlasRb::Middleware::RaiseOnReadOnlyMode
420
+ builder.use AtlasRb::Middleware::RaiseOnBackendUnavailable
416
421
  end
417
422
 
418
423
  # Retry an idempotent read that failed in transport, in exactly one layer.
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AtlasRb
4
+ # Atlas's readiness report (`GET /health`): whether it can reach its database,
5
+ # its Solr index, its storage and its cache.
6
+ #
7
+ # Atlas answers `200` when every required dependency answers and `503` when
8
+ # one does not, with the same body either way:
9
+ #
10
+ # { "status" => "degraded", "database" => "ok", "index" => "unavailable",
11
+ # "storage" => "ok", "cache" => "ok" }
12
+ #
13
+ # The endpoint is unauthenticated, so this sends no credential, and works
14
+ # without a signing key or `ATLAS_JWT` configured.
15
+ #
16
+ # Not to be confused with `GET /up`, the liveness route the orchestrator
17
+ # polls; it has no binding.
18
+ class Health
19
+ extend AtlasRb::FaradayHelper
20
+
21
+ # Seconds to wait for a socket to open.
22
+ OPEN_TIMEOUT = 2
23
+
24
+ # Seconds to wait for the report. Atlas bounds each of its own probes, so a
25
+ # slower answer means Atlas itself is struggling. Short enough to answer
26
+ # inside a monitor's own deadline (Nagios's `check_http` defaults to 10s).
27
+ READ_TIMEOUT = 5
28
+
29
+ # Read the report.
30
+ #
31
+ # A `503` here is an answer, not an error, so it is returned like a `200`;
32
+ # read `status` off the result. There is no retry: a probe must answer
33
+ # faster than its poller times out, and a retry would hide the very
34
+ # flakiness it exists to show.
35
+ #
36
+ # @param timeout [Numeric] seconds to wait for the report.
37
+ # @return [AtlasRb::Mash] `status` (`"ok"` / `"degraded"` /
38
+ # `"unavailable"`), and `database`, `index`, `storage` (`"ok"` /
39
+ # `"unavailable"`) and `cache` (`"ok"` / `"unavailable"` / `"disabled"`).
40
+ # @raise [Faraday::ConnectionFailed, Faraday::TimeoutError] when Atlas
41
+ # does not answer at all.
42
+ # @raise [AtlasRb::ResourceError] when something answers that is not
43
+ # Atlas's report: a `404` from an Atlas that predates the endpoint, or a
44
+ # proxy's `502` with an HTML body.
45
+ def self.read(timeout: READ_TIMEOUT)
46
+ resp = health_connection(timeout).get("/health")
47
+ report = parse_report(resp)
48
+ return AtlasRb::Mash.new(report) if report
49
+
50
+ raise AtlasRb::ResourceError.new("#{request_target(resp)} → #{resp.status}: #{resp.body}", response: resp)
51
+ end
52
+
53
+ class << self
54
+ private
55
+
56
+ # A fresh socket per call rather than the shared pool. With no retry, a
57
+ # pooled socket the server had closed would report a healthy Atlas as
58
+ # down; a probe runs far too rarely for the handshake to matter.
59
+ def health_connection(timeout)
60
+ Faraday.new(url: ENV.fetch("ATLAS_URL", nil)) do |f|
61
+ f.options.open_timeout = OPEN_TIMEOUT
62
+ f.options.timeout = timeout
63
+ instrument(f)
64
+ f.adapter :net_http
65
+ end
66
+ end
67
+
68
+ def parse_report(resp)
69
+ return nil unless [200, 503].include?(resp.status)
70
+
71
+ body = JSON.parse(resp.body.to_s)
72
+ body.is_a?(Hash) && body.key?("status") ? body : nil
73
+ rescue JSON::ParserError
74
+ nil
75
+ end
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AtlasRb
4
+ # Atlas's own operational figures. Admin- or `:system`-gated in Atlas.
5
+ class Metrics
6
+ extend AtlasRb::FaradayHelper
7
+
8
+ # Seconds to wait for a socket to open.
9
+ OPEN_TIMEOUT = 2
10
+
11
+ # Seconds to wait for the report. It reads at most 120 small Redis hashes,
12
+ # so a slower answer means Atlas is struggling, and an admin page should
13
+ # not wait on it.
14
+ READ_TIMEOUT = 5
15
+
16
+ # How long Atlas took to serve its own requests (`GET /metrics/latency`).
17
+ #
18
+ # Measured inside Atlas, from when Rails dispatches the action until it
19
+ # returns, so it leaves out the network and the caller's own waiting. A
20
+ # caller that also times its Atlas calls can subtract to see how much of
21
+ # its wait was its own. The percentiles are interpolated from histogram
22
+ # buckets, so they are approximate.
23
+ #
24
+ # No retry, and a fresh socket per call: like {AtlasRb::Health.read}, it is
25
+ # polled rarely and should fail fast rather than hide a struggling Atlas.
26
+ #
27
+ # @param minutes [Integer] the window, 1 to 120. Atlas clamps it.
28
+ # @param nuid [String, nil] the acting NUID, signed into the assertion
29
+ # `sub`. It must name an admin; falls through to
30
+ # {AtlasRb.config#default_nuid}.
31
+ # @param on_behalf_of [String, nil] optional NUID carried as a signed `obo`
32
+ # claim.
33
+ # @return [AtlasRb::Mash] `window_minutes`, `calls`, `server_errors`,
34
+ # `mean_ms`, `p50_ms`, `p95_ms`, `p99_ms` (Integer, or nil with no calls),
35
+ # and `slowest_actions`, an Array of `action` / `calls` / `p95_ms`.
36
+ # @raise [AtlasRb::BackendUnavailableError] when Atlas answers that Redis,
37
+ # which holds the figures, is down (`dependency == "cache"`).
38
+ # @raise [AtlasRb::ResourceError] on any other non-200, notably a `403`
39
+ # for a caller who is not an admin.
40
+ # @raise [Faraday::ConnectionFailed, Faraday::TimeoutError] when Atlas does
41
+ # not answer at all.
42
+ #
43
+ # @example
44
+ # latency = AtlasRb::Metrics.latency(minutes: 15, nuid: current_user.nuid)
45
+ # latency.p95_ms # => 98
46
+ def self.latency(minutes: 60, nuid: nil, on_behalf_of: nil)
47
+ resp = metrics_connection(nuid, on_behalf_of).get("/metrics/latency", minutes: minutes)
48
+ return AtlasRb::Mash.new(JSON.parse(resp.body)) if resp.status == 200
49
+
50
+ raise AtlasRb::ResourceError.new("#{request_target(resp)} → #{resp.status}: #{resp.body}", response: resp)
51
+ end
52
+
53
+ class << self
54
+ private
55
+
56
+ def metrics_connection(nuid, on_behalf_of)
57
+ headers = auth_headers(nuid, on_behalf_of).merge("Content-Type" => "application/json")
58
+ Faraday.new(url: ENV.fetch("ATLAS_URL", nil), headers: headers) do |f|
59
+ f.options.open_timeout = OPEN_TIMEOUT
60
+ f.options.timeout = READ_TIMEOUT
61
+ instrument(f)
62
+ f.use AtlasRb::Middleware::RaiseOnBackendUnavailable
63
+ f.adapter :net_http
64
+ end
65
+ end
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AtlasRb
4
+ module Middleware
5
+ # Translates Atlas's dependency-outage answer into a typed Ruby exception.
6
+ #
7
+ # When Atlas cannot reach its database, its Solr index or its storage, it
8
+ # answers `503 Service Unavailable` with the discriminator
9
+ # `error: "backend_unavailable"`, a `dependency` key and a `Retry-After`
10
+ # header. This middleware keys on the **status + discriminator pair** and
11
+ # raises {AtlasRb::BackendUnavailableError}.
12
+ #
13
+ # Path-independent, like {RaiseOnReadOnlyMode}: an outage can answer any
14
+ # request. A 503 without Atlas's envelope — a proxy while Atlas restarts —
15
+ # passes through untouched, because that is a different condition.
16
+ class RaiseOnBackendUnavailable < Faraday::Middleware
17
+ # The discriminator Atlas renders. An exact-match wire contract.
18
+ CODE = "backend_unavailable"
19
+
20
+ # @param env [Faraday::Env] the completed response environment.
21
+ # @raise [AtlasRb::BackendUnavailableError] on a 503 whose body carries
22
+ # `error: "backend_unavailable"`.
23
+ # @return [void]
24
+ def on_complete(env)
25
+ return unless env.status == 503
26
+
27
+ body = parse_json(env.body)
28
+ return unless body.is_a?(Hash) && body["error"] == CODE
29
+
30
+ raise AtlasRb::BackendUnavailableError.new(
31
+ body["message"],
32
+ code: body["error"],
33
+ dependency: body["dependency"],
34
+ retry_after: env.response_headers&.[]("retry-after")&.to_i
35
+ )
36
+ end
37
+
38
+ private
39
+
40
+ def parse_json(body)
41
+ return body if body.is_a?(Hash)
42
+
43
+ JSON.parse(body.to_s)
44
+ rescue JSON::ParserError
45
+ nil
46
+ end
47
+ end
48
+ end
49
+ end
data/lib/atlas_rb.rb CHANGED
@@ -21,6 +21,7 @@ require_relative "atlas_rb/middleware/raise_on_read_error"
21
21
  require_relative "atlas_rb/middleware/raise_on_stale_resource"
22
22
  require_relative "atlas_rb/middleware/raise_on_resource_error"
23
23
  require_relative "atlas_rb/middleware/raise_on_read_only_mode"
24
+ require_relative "atlas_rb/middleware/raise_on_backend_unavailable"
24
25
  require_relative "atlas_rb/faraday_helper"
25
26
  require_relative "atlas_rb/mash"
26
27
  require_relative "atlas_rb/authentication"
@@ -46,6 +47,8 @@ require_relative "atlas_rb/system/token"
46
47
  require_relative "atlas_rb/system/work"
47
48
  require_relative "atlas_rb/audit_event"
48
49
  require_relative "atlas_rb/maintenance"
50
+ require_relative "atlas_rb/health"
51
+ require_relative "atlas_rb/metrics"
49
52
 
50
53
  # Ruby client for the Atlas API — Northeastern University's institutional
51
54
  # digital repository.
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: atlas_rb
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.27.0
4
+ version: 1.27.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - David Cliff
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-10-19 00:00:00.000000000 Z
11
+ date: 2026-10-22 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: faraday
@@ -170,8 +170,11 @@ files:
170
170
  - lib/atlas_rb/errors.rb
171
171
  - lib/atlas_rb/faraday_helper.rb
172
172
  - lib/atlas_rb/file_set.rb
173
+ - lib/atlas_rb/health.rb
173
174
  - lib/atlas_rb/maintenance.rb
174
175
  - lib/atlas_rb/mash.rb
176
+ - lib/atlas_rb/metrics.rb
177
+ - lib/atlas_rb/middleware/raise_on_backend_unavailable.rb
175
178
  - lib/atlas_rb/middleware/raise_on_read_error.rb
176
179
  - lib/atlas_rb/middleware/raise_on_read_only_mode.rb
177
180
  - lib/atlas_rb/middleware/raise_on_resource_error.rb