atlas_rb 1.27.0 → 1.27.1

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: 2c1b0723d54145c8be9868a1849502cf80adbc1a30b2f7b1ee39aadbfa1a2f3b
4
+ data.tar.gz: 7ab2bb5a662b02dd48275e645f9c6b3c1226d6bc995231766bd5a4252f17b25a
5
5
  SHA512:
6
- metadata.gz: d33812411e71cfa8bf0031fae4f81b735d598b75cb31f099e0c67a31d8dc0e4cc9c193524443f8dce983f87f96add5c9cc82c6de8c3d1d4211e69611254d4cc6
7
- data.tar.gz: f287d655a86594da49f54caa8cb505277577b3b81284e313cf956ee6424fd09f3bdf0085ae51fae14420468c386626eba3c300be3f41e5762d57c65de5938213
6
+ metadata.gz: 64f43d675ad66dac85657c0460702f53f95c99760e7a44ce6f0dec019e375f41b197c214ca3dc4d89a94a1e0b829fa9d377fd4dd9432fe68cdc53d9aa490e38e
7
+ data.tar.gz: dfc0ade69d92b8b100698091f6ec22d90eb428769bb61aebdc79a9e842840036bc45875bc330531be335d5a11459c823ff13a1ccd78e1b143b11009f083ca54b
data/.version CHANGED
@@ -1 +1 @@
1
- 1.27.0
1
+ 1.27.1
data/CHANGELOG.md CHANGED
@@ -1,5 +1,25 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.27.1
4
+
5
+ Requires an Atlas that serves `GET /health` and the `backend_unavailable`
6
+ envelope.
7
+
8
+ ### Added — `AtlasRb::Health.read`
9
+
10
+ Reads Atlas's readiness report (`GET /health`). Returns the parsed body on a
11
+ `200` or a `503`, because a `503` there is an answer. Raises only on
12
+ transport failure, or `ResourceError` when the answer is not the report. No
13
+ credential, a 5-second deadline, no retry, and a fresh socket per call.
14
+
15
+ ### Added — `AtlasRb::BackendUnavailableError`
16
+
17
+ Raised on every binding when Atlas answers `503` with
18
+ `error: "backend_unavailable"`. Carries `dependency` (`"database"`,
19
+ `"index"` or `"storage"`) and `retry_after`. A sibling of
20
+ `ReadOnlyModeError`, not a `ResourceError`. A bodyless `503` from a proxy
21
+ still passes through.
22
+
3
23
  ## 1.27.0
4
24
 
5
25
  ### 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.1)
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,44 @@ 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
+
822
860
  ## End-to-end example
823
861
 
824
862
  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,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,7 @@ 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"
49
51
 
50
52
  # Ruby client for the Atlas API — Northeastern University's institutional
51
53
  # 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.1
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,10 @@ 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/middleware/raise_on_backend_unavailable.rb
175
177
  - lib/atlas_rb/middleware/raise_on_read_error.rb
176
178
  - lib/atlas_rb/middleware/raise_on_read_only_mode.rb
177
179
  - lib/atlas_rb/middleware/raise_on_resource_error.rb