w3c_api 0.3.2 → 0.3.4

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: f45cd3142ec5509995fee4a123992a0f74d1abc6fcc4c0477909855d017b5630
4
- data.tar.gz: 03cf1921072c56fba3024094242f8225caed25a603471e5f8358272b01ad4b92
3
+ metadata.gz: 307e08b89228dff85498435c4ec1e7893a61ed4363d338f02a434dacc7880866
4
+ data.tar.gz: 4561cf4b6b4ae80c65d4a5a6edc3a7e2d8d98a8ca09df41ad6ca47bcee6940a9
5
5
  SHA512:
6
- metadata.gz: 57a1d8396a7bba59cff32430641bf90a75a251f9b8ea227710ec6e27bd4fc1fef2d5d9c667bfb7e5a4c6782e099ad96ddd72060e1c9973fbe9c7068b223b4845
7
- data.tar.gz: b0178bf92b0e73a16d226aeeb0c91f0cee3ca3b91857a7b6241ff2423a604ca62181c9edb76af63833b875cf314842ac572badb9dd158a8bcbb172612a21bf03
6
+ metadata.gz: b7343b0927f3b330a8f6fa8c19f388557206dbbdfb16bfa94cc3800eb6b7a3fdcdd01c1ce9296fee17e6bc98961c6248edb82ca4e12b8479c7014ef7cd3eb21d
7
+ data.tar.gz: fb1e3a885d85112a4778c8b763857df9840214885176b83a69e8488fdc76a3fc26d7fde8bfa481666186bb99afe78917b93c35687f1969a2aaf09397f48c0cef
data/CLAUDE.md CHANGED
@@ -74,13 +74,58 @@ that embedded data instead of issuing a new HTTP request (see
74
74
  Two cooperating layers, both tuned to grow 1→2→4→8→16s:
75
75
  - lutaml-hal's `RateLimiter` (via `rate_limiting_options`) retries **429 and
76
76
  5xx**.
77
- - A Faraday `:retry` middleware in `connection` covers what lutaml-hal does not:
78
- the W3C API signals rate-limiting with **HTTP 403**, plus connection/timeout
79
- errors.
77
+ - A Faraday `:retry` middleware in `connection` (`DEFAULT_RETRY_OPTIONS`) covers
78
+ what lutaml-hal does not: the W3C API signals rate-limiting with **HTTP 403**,
79
+ plus connection/timeout errors.
80
+
81
+ Two footguns in the Faraday layer, both encoded in constants in `hal.rb` — do
82
+ not "tidy" them away (see issue #23):
83
+ - `exceptions:` **replaces** faraday-retry's `DEFAULT_EXCEPTIONS`, it does not
84
+ merge. `retry_statuses: [403]` is implemented by raising
85
+ `Faraday::RetriableResponse` internally and rescuing it via `exceptions:`, so
86
+ dropping that class disables 403 retries *and* leaks the raise to the caller
87
+ on the first 403. Hence `RETRY_EXCEPTIONS` is built on top of the defaults.
88
+ - `max_interval` caps `Retry-After` as well as the computed backoff, and a
89
+ `Retry-After` above it makes faraday-retry stop retrying entirely rather than
90
+ wait longer. Hence 60s, well above the largest computed backoff (16s).
80
91
 
81
92
  Owning retries in the client means consumers get resilience without wrapping.
82
- Tune via `Hal.instance.configure_rate_limiting(...)`; changing options resets
83
- the memoized client.
93
+ Tune via `Hal.instance.configure_rate_limiting(...)` and
94
+ `Hal.instance.configure_retry(...)` — both go through the memoization cascade
95
+ below. There is no `disable_retry`; `configure_retry(max: 0)` is the off switch.
96
+
97
+ Once retries are exhausted a 403 surfaces as `Lutaml::Hal::Error` (its
98
+ `handle_response` has no 403 branch, so 403 falls through to the generic
99
+ `raise Error`), not as a `Faraday::RetriableResponse`.
100
+
101
+ ### User agent (`hal.rb`)
102
+
103
+ Requests carry `w3c_api/<VERSION> (+<repo url>)` (`DEFAULT_USER_AGENT`) — a bare
104
+ Faraday user agent is a prime trigger for the Cloudflare bot heuristics in front
105
+ of `api.w3.org`, and W3C asks consumers to identify themselves. Override with
106
+ `Hal.instance.configure_user_agent(...)` (wins), the `W3C_API_USER_AGENT`
107
+ environment variable, or the CLI's `--user-agent` flag. The flag comes from
108
+ `Commands::UserAgentOption`, mixed into every command class — Thor cannot parse
109
+ options placed before a subcommand name, so it cannot live on the root `Cli`.
110
+
111
+ Note that per-request `headers:` passed to `Client` methods never reach the wire:
112
+ lutaml-hal only forwards headers declared as endpoint parameters with
113
+ `location: :header`, and `SimpleParameter` only produces `:path`/`:query`. The
114
+ connection-level user agent is the working mechanism.
115
+
116
+ ### Memoization cascade (`hal.rb`)
117
+
118
+ Memoization runs `@register → @client → @connection`, and `ModelRegister` keeps
119
+ the client it was built with — so any connection- or client-level change must
120
+ rebuild all three. Every `configure_*` setter funnels through
121
+ `reset_connection → reset_client → rebuild_register` accordingly.
122
+
123
+ `rebuild_register` rebuilds **eagerly**, and that is load-bearing: `Link#realize`
124
+ resolves the register via `GlobalRegister.instance.get(:w3c_api)`, which *raises*
125
+ when the name is absent. A lazy `reset_register` would leave every
126
+ already-fetched model unable to realize its links until something re-entered
127
+ `Hal#register`. The trade-off is that each `configure_*` call starts a fresh
128
+ object cache — they are start-up knobs, not mid-crawl ones.
84
129
 
85
130
  ### Caching (`hal.rb`)
86
131
 
@@ -98,10 +143,22 @@ unregisters from lutaml-hal's `GlobalRegister`, otherwise the rebuild raises
98
143
  Specs use **VCR** (`hook_into :faraday`) with cassettes in
99
144
  `spec/fixtures/vcr_cassettes/`. Default record mode is `:new_episodes` and
100
145
  requests match on `method, uri, body` — so a new test that hits an unrecorded
101
- request will perform a real HTTP call and record it. `spec_helper.rb` resets the
102
- `Hal` singleton's register and the lutaml-hal `GlobalRegister` around every
103
- example to prevent cross-test endpoint-registration bleed — which also gives
104
- each example a fresh object cache, so caching doesn't mask expected requests.
146
+ request will perform a real HTTP call and record it (pass `record: :none` to a
147
+ cassette to make a mismatch raise instead). `spec_helper.rb` calls
148
+ `configure_user_agent(nil)` before every example, which cascades through the
149
+ whole memoization chain — connection, client, register — and unregisters from
150
+ the lutaml-hal `GlobalRegister` to prevent cross-test endpoint-registration
151
+ bleed. That also gives each example a fresh object cache, so caching doesn't
152
+ mask expected requests.
153
+
154
+ `spec/w3c_api/hal_spec.rb` is the one spec that opts out of VCR: it drives a
155
+ `Faraday::Adapter::Test` connection and wraps every example in
156
+ `VCR.turned_off`, because the `:faraday` hook injects VCR into *that*
157
+ connection too and would otherwise reject the request as unhandled. It also
158
+ restores `@retry_options` in an `around` hook: the per-example
159
+ `configure_user_agent(nil)` rebuilds connection, client and register, but
160
+ nothing resets the retry options, so a `configure_retry` call would otherwise
161
+ persist for the whole suite process.
105
162
 
106
163
  ## Conventions
107
164
 
data/README.adoc CHANGED
@@ -17,7 +17,8 @@ https://github.com/lutaml/lutaml-model[lutaml-model]
17
17
  for all W3C API resources
18
18
  * HAL (Hypertext Application Language) implementation for recursive resource
19
19
  traversal
20
- * Built-in rate limiting with exponential backoff for reliable API access
20
+ * Built-in rate limiting with exponential backoff, plus automatic retries of
21
+ HTTP 403 and transient network errors, for reliable API access
21
22
  * A command-line interface using Thor following GitHub CLI patterns
22
23
 
23
24
  The endpoint supported is at https://api.w3.org.
@@ -90,7 +91,7 @@ client = W3cApi::Client.new
90
91
  W3cApi::Hal.instance.configure_rate_limiting(
91
92
  max_retries: 5, # Maximum retry attempts
92
93
  base_delay: 1.0, # Initial delay in seconds
93
- max_delay: 60.0, # Maximum delay cap
94
+ max_delay: 30.0, # Maximum delay cap
94
95
  backoff_factor: 2.0 # Exponential backoff multiplier
95
96
  )
96
97
 
@@ -98,6 +99,46 @@ W3cApi::Hal.instance.configure_rate_limiting(
98
99
  specifications = client.specifications
99
100
  ----
100
101
 
102
+ ==== Retrying HTTP 403
103
+
104
+ The W3C API signals rate limiting with `HTTP 403` rather than `429`, which the
105
+ rate limiter above does not cover. A Faraday retry middleware in the shared
106
+ connection handles it: a `403` response is retried up to 5 times with
107
+ exponential backoff (1, 2, 4, 8, 16 seconds), as are connection and timeout
108
+ errors. If the response carries a `Retry-After` (or `RateLimit-Reset`) header,
109
+ that value is used in place of the computed backoff when it is the longer of the
110
+ two -- unless it exceeds `max_interval`, in which case the middleware gives up
111
+ rather than waiting. See the note below.
112
+
113
+ [source,ruby]
114
+ ----
115
+ W3cApi::Hal.instance.configure_retry(
116
+ max: 5, # Maximum retry attempts
117
+ interval: 1.0, # Initial delay in seconds
118
+ backoff_factor: 2, # Exponential backoff multiplier
119
+ max_interval: 60.0, # Delay cap -- also caps `Retry-After`, see below
120
+ retry_statuses: [403]
121
+ )
122
+
123
+ # Effectively turn retrying off
124
+ W3cApi::Hal.instance.configure_retry(max: 0)
125
+ ----
126
+
127
+ Options are merged into the current ones, and calling `configure_retry` rebuilds
128
+ the connection, the client and the endpoint register -- so, like the other
129
+ `configure_*` setters, call it once at start-up rather than mid-crawl (see the
130
+ note under <<User agent>>).
131
+
132
+ [NOTE]
133
+ ====
134
+ `max_interval` bounds the `Retry-After` header as well as the computed backoff,
135
+ but it does not clamp it. If a server asks for longer than `max_interval`, the
136
+ middleware stops retrying rather than waiting longer, so keep `max_interval`
137
+ comfortably above the largest `Retry-After` you expect.
138
+ ====
139
+
140
+ If every retry is exhausted, the final `403` surfaces as a `Lutaml::Hal::Error`.
141
+
101
142
  ==== Demo and examples
102
143
 
103
144
  To see rate limiting in action, run the included demonstration scripts:
@@ -123,6 +164,43 @@ The stress test example demonstrates:
123
164
  * Dynamic configuration changes
124
165
  * Bulk operation patterns
125
166
 
167
+ === User agent
168
+
169
+ Every request identifies the gem, its version and a contact URL, e.g. `w3c_api/0.3.2 (+https://github.com/relaton/w3c_api)`.
170
+
171
+ `api.w3.org` is fronted by Cloudflare, and a generic HTTP library user agent (the Faraday default) is a common trigger for its bot and rate limiting heuristics. Applications that embed this gem should add their own identity so that W3C can tell traffic sources apart and reach the operator instead of throttling silently.
172
+
173
+ ==== Quick start
174
+
175
+ [source,ruby]
176
+ ----
177
+ require 'w3c_api'
178
+
179
+ # Inspect the current value
180
+ W3cApi::Hal.instance.user_agent
181
+ # => "w3c_api/0.3.2 (+https://github.com/relaton/w3c_api)"
182
+
183
+ # Identify your application, keeping the gem's own token
184
+ W3cApi::Hal.instance.configure_user_agent(
185
+ "my-crawler/1.4 (+https://example.com/contact) #{W3cApi::Hal::DEFAULT_USER_AGENT}"
186
+ )
187
+
188
+ # Restore the default (or the W3C_API_USER_AGENT value)
189
+ W3cApi::Hal.instance.configure_user_agent(nil)
190
+ ----
191
+
192
+ Alternatively, set the `W3C_API_USER_AGENT` environment variable, or pass `--user-agent` to any CLI command:
193
+
194
+ [source,shell]
195
+ ----
196
+ $ export W3C_API_USER_AGENT="my-crawler/1.4 (+https://example.com/contact)"
197
+ $ w3c_api specification fetch --user-agent "my-crawler/1.4 (+https://example.com/contact)"
198
+ ----
199
+
200
+ An explicit `configure_user_agent` call (which is what `--user-agent` performs) takes precedence over the environment variable, which in turn takes precedence over the gem default.
201
+
202
+ NOTE: Changing the user agent rebuilds the HTTP connection, the client and the endpoint register — as do `configure_rate_limiting` and the cache setters. The rebuilt register starts with an empty in-memory object cache, so configure these once at start-up, before issuing requests, rather than mid-crawl. Models fetched earlier keep working: the register is rebuilt eagerly, so their links still realize.
203
+
126
204
  === Caching
127
205
 
128
206
  Realized objects are cached keyed by their (canonical) URL, so a resource linked from many places — editors, working groups, versions — is fetched only once per process. Caching is enabled by default (in-memory) and works transparently with `fetch` and link `.realize`.
@@ -311,9 +389,10 @@ end
311
389
  specifications = client.specifications(page: 2, per_page: 50)
312
390
  groups = client.groups(page: 1, per_page: 10, limit: 25, offset: 100)
313
391
 
314
- # HTTP client options
315
- user = client.user('hash', timeout: 30, headers: { 'User-Agent' => 'MyApp/1.0' })
316
- spec = client.specification('html5', read_timeout: 45, open_timeout: 10)
392
+ # NOTE: HTTP-level settings are not per-request. Options such as `headers:`,
393
+ # `timeout:`, `read_timeout:` and `open_timeout:` are not endpoint parameters
394
+ # and are silently ignored. Configure the user agent connection-wide with
395
+ # W3cApi::Hal.instance.configure_user_agent (see "User agent" above).
317
396
 
318
397
  # Query parameters for filtering and sorting
319
398
  rec_specs = client.specifications_by_status('REC', sort: 'date', order: 'desc')
@@ -323,8 +402,6 @@ active_groups = client.groups(type: 'working-group', status: 'active')
323
402
  options = {
324
403
  page: 1,
325
404
  per_page: 25,
326
- headers: { 'Accept-Language' => 'en-US' },
327
- timeout: 60,
328
405
  sort: 'name'
329
406
  }
330
407
  specs = client.specifications(options)
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "thor"
4
4
  require_relative "output_formatter"
5
+ require_relative "user_agent_option"
5
6
  require_relative "../client"
6
7
 
7
8
  module W3cApi
@@ -9,6 +10,7 @@ module W3cApi
9
10
  # Thor CLI command for affiliation operations
10
11
  class Affiliation < Thor
11
12
  include OutputFormatter
13
+ include UserAgentOption
12
14
 
13
15
  desc "fetch [OPTIONS]", "Fetch affiliations"
14
16
  option :id, type: :numeric, desc: "Affiliation ID"
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "thor"
4
4
  require_relative "output_formatter"
5
+ require_relative "user_agent_option"
5
6
  require_relative "../client"
6
7
 
7
8
  module W3cApi
@@ -9,6 +10,7 @@ module W3cApi
9
10
  # Thor CLI command for ecosystem operations
10
11
  class Ecosystem < Thor
11
12
  include OutputFormatter
13
+ include UserAgentOption
12
14
 
13
15
  desc "fetch [OPTIONS]", "Fetch ecosystems"
14
16
  option :shortname, type: :string, desc: "Ecosystem shortname"
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "thor"
4
4
  require_relative "output_formatter"
5
+ require_relative "user_agent_option"
5
6
  require_relative "../client"
6
7
 
7
8
  module W3cApi
@@ -9,6 +10,7 @@ module W3cApi
9
10
  # Thor CLI command for group operations
10
11
  class Group < Thor
11
12
  include OutputFormatter
13
+ include UserAgentOption
12
14
 
13
15
  desc "fetch [OPTIONS]", "Fetch groups"
14
16
  option :id, type: :string, desc: "Group ID"
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "thor"
4
4
  require_relative "output_formatter"
5
+ require_relative "user_agent_option"
5
6
  require_relative "../client"
6
7
 
7
8
  module W3cApi
@@ -9,6 +10,7 @@ module W3cApi
9
10
  # Thor CLI command for participation operations
10
11
  class Participation < Thor
11
12
  include OutputFormatter
13
+ include UserAgentOption
12
14
 
13
15
  desc "fetch", "Fetch a participation by ID"
14
16
  option :id, type: :numeric, required: true, desc: "Participation ID"
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "thor"
4
4
  require_relative "output_formatter"
5
+ require_relative "user_agent_option"
5
6
  require_relative "../client"
6
7
 
7
8
  module W3cApi
@@ -9,6 +10,7 @@ module W3cApi
9
10
  # Thor CLI command for series operations
10
11
  class Series < Thor
11
12
  include OutputFormatter
13
+ include UserAgentOption
12
14
 
13
15
  desc "fetch [OPTIONS]", "Fetch specification series"
14
16
  option :shortname, type: :string, desc: "Series shortname"
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "thor"
4
4
  require_relative "output_formatter"
5
+ require_relative "user_agent_option"
5
6
  require_relative "../client"
6
7
 
7
8
  module W3cApi
@@ -9,6 +10,7 @@ module W3cApi
9
10
  # Thor CLI command for specification operations
10
11
  class Specification < Thor
11
12
  include OutputFormatter
13
+ include UserAgentOption
12
14
 
13
15
  desc "fetch [OPTIONS]", "Fetch specifications"
14
16
  option :shortname, type: :string, desc: "Filter by shortname"
@@ -2,11 +2,13 @@
2
2
 
3
3
  require "thor"
4
4
  require_relative "output_formatter"
5
+ require_relative "user_agent_option"
5
6
 
6
7
  module W3cApi
7
8
  module Commands
8
9
  class SpecificationVersion < Thor
9
10
  include OutputFormatter
11
+ include UserAgentOption
10
12
 
11
13
  desc "editors", "Fetch editors of a specification version"
12
14
  option :shortname, type: :string, required: true,
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "thor"
4
4
  require_relative "output_formatter"
5
+ require_relative "user_agent_option"
5
6
  require_relative "../client"
6
7
 
7
8
  module W3cApi
@@ -9,6 +10,7 @@ module W3cApi
9
10
  # Thor CLI command for translation operations
10
11
  class Translation < Thor
11
12
  include OutputFormatter
13
+ include UserAgentOption
12
14
 
13
15
  desc "fetch [OPTIONS]", "Fetch a translation by ID"
14
16
  option :id, type: :string, desc: "Translation ID"
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "thor"
4
4
  require_relative "output_formatter"
5
+ require_relative "user_agent_option"
5
6
  require_relative "../client"
6
7
 
7
8
  module W3cApi
@@ -9,6 +10,7 @@ module W3cApi
9
10
  # Thor CLI command for user operations
10
11
  class User < Thor
11
12
  include OutputFormatter
13
+ include UserAgentOption
12
14
 
13
15
  desc "fetch [OPTIONS]", "Fetch a user by ID"
14
16
  option :id, type: :string, required: true, desc: "User ID (required)"
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../hal"
4
+
5
+ module W3cApi
6
+ module Commands
7
+ # Adds a --user-agent flag to a command class and applies it before the
8
+ # command runs. Thor cannot parse options that precede a subcommand name,
9
+ # so the flag lives on each command class rather than on the root Cli:
10
+ #
11
+ # w3c_api specification fetch --user-agent "my-crawler/1.0 (+url)"
12
+ module UserAgentOption
13
+ def self.included(base)
14
+ base.class_option :user_agent,
15
+ type: :string,
16
+ desc: "User-Agent sent to api.w3.org " \
17
+ "(default: #{Hal::DEFAULT_USER_AGENT})"
18
+ end
19
+
20
+ def initialize(*args)
21
+ super
22
+ # Configure before the command body builds a Client: changing the user
23
+ # agent rebuilds the connection, client and register.
24
+ Hal.instance.configure_user_agent(options[:user_agent]) if options[:user_agent]
25
+ end
26
+ end
27
+ end
28
+ end
data/lib/w3c_api/hal.rb CHANGED
@@ -2,7 +2,9 @@
2
2
 
3
3
  require "singleton"
4
4
  require "faraday/retry"
5
+ require "faraday/follow_redirects"
5
6
  require "lutaml/hal"
7
+ require_relative "version"
6
8
  require_relative "models"
7
9
 
8
10
  module W3cApi
@@ -44,6 +46,51 @@ module W3cApi
44
46
 
45
47
  API_URL = "https://api.w3.org/"
46
48
 
49
+ # Exceptions the Faraday retry middleware treats as retriable.
50
+ #
51
+ # faraday-retry implements `retry_statuses` by raising
52
+ # Faraday::RetriableResponse internally and rescuing it with a matcher built
53
+ # from `exceptions:`. Passing `exceptions:` REPLACES faraday-retry's
54
+ # DEFAULT_EXCEPTIONS, it does not merge with them -- so omitting
55
+ # Faraday::RetriableResponse silently disables `retry_statuses` *and* lets
56
+ # the internal raise escape to the caller on the very first matching
57
+ # response. Always build this list on top of the defaults.
58
+ RETRY_EXCEPTIONS = (
59
+ Faraday::Retry::Middleware::DEFAULT_EXCEPTIONS + [Faraday::ConnectionFailed]
60
+ ).freeze
61
+
62
+ # Retry policy for the W3C-specific transient failures: HTTP 403 (how the
63
+ # W3C API signals rate-limiting) plus connection/timeout errors. The
64
+ # computed backoff grows 1, 2, 4, 8, 16s, matching rate_limiting_options.
65
+ #
66
+ # max_interval sits far above the largest computed backoff (16s) because it
67
+ # also caps `Retry-After`: faraday-retry's `calculate_sleep_amount` returns
68
+ # nil -- aborting the retry loop entirely, with zero retries -- when a
69
+ # response's `Retry-After` exceeds max_interval. Too small a cap turns a
70
+ # polite server hint into an immediate give-up.
71
+ DEFAULT_RETRY_OPTIONS = {
72
+ max: 5,
73
+ interval: 1.0,
74
+ backoff_factor: 2,
75
+ max_interval: 60.0,
76
+ # Both nested values are frozen too: Hash#freeze is shallow, and
77
+ # retry_options hands out a shallow dup, so an unfrozen array here would
78
+ # let a caller corrupt this baseline for the whole process.
79
+ retry_statuses: [403].freeze,
80
+ exceptions: RETRY_EXCEPTIONS,
81
+ }.freeze
82
+
83
+ # api.w3.org is fronted by Cloudflare, and a bare HTTP-library User-Agent
84
+ # such as "Faraday v2.12.2" is a well-known trigger for its bot heuristics —
85
+ # which is how a polite crawl turns into an all-or-nothing 429 storm. W3C
86
+ # also asks API consumers to identify themselves, so name the gem, its
87
+ # version, and a contact URL.
88
+ DEFAULT_USER_AGENT = "w3c_api/#{VERSION} (+https://github.com/relaton/w3c_api)".freeze
89
+
90
+ # Lets an operator put their own contact details on the wire without a code
91
+ # change. An explicit configure_user_agent call still wins.
92
+ USER_AGENT_ENV_VAR = "W3C_API_USER_AGENT"
93
+
47
94
  def initialize
48
95
  # Don't call setup here - it will be called when register is first accessed
49
96
  end
@@ -56,35 +103,69 @@ module W3cApi
56
103
  )
57
104
  end
58
105
 
106
+ # User-Agent sent with every request. Precedence: an explicit
107
+ # configure_user_agent call, then W3C_API_USER_AGENT, then the gem default.
108
+ def user_agent
109
+ @user_agent ||=
110
+ normalize_user_agent(ENV.fetch(USER_AGENT_ENV_VAR, nil)) ||
111
+ DEFAULT_USER_AGENT
112
+ end
113
+
114
+ # Set the User-Agent sent with every request.
115
+ #
116
+ # Applications embedding this gem should identify themselves and keep the
117
+ # gem's own token, e.g.
118
+ # configure_user_agent("my-crawler/1.4 (+https://example.com) #{DEFAULT_USER_AGENT}")
119
+ #
120
+ # Pass nil or a blank string to fall back to W3C_API_USER_AGENT / the gem
121
+ # default. Rebuilds the connection, so call it before issuing requests.
122
+ def configure_user_agent(user_agent)
123
+ @user_agent = normalize_user_agent(user_agent)
124
+ reset_connection
125
+ self.user_agent
126
+ end
127
+
59
128
  # Faraday connection mirroring lutaml-hal's default middleware stack, with a
60
129
  # retry layer for the failures lutaml-hal's RateLimiter does not cover: the
61
130
  # W3C API signals rate-limiting with HTTP 403, plus transient connection and
62
131
  # timeout errors. (lutaml-hal still retries 429 and 5xx.) Owning retries here
63
132
  # means every consumer of the client is resilient without its own wrapper.
64
133
  def connection
65
- @connection ||= Faraday.new(url: API_URL.delete_suffix("/")) do |conn|
134
+ @connection ||= Faraday.new(
135
+ url: API_URL.delete_suffix("/"),
136
+ headers: { "User-Agent" => user_agent },
137
+ ) do |conn|
66
138
  conn.request :retry, retry_options
67
139
  conn.use Faraday::FollowRedirects::Middleware
68
- conn.request :json
69
- conn.response :json, content_type: /\bjson$/
140
+ # No `response :json`: Faraday's middleware parses with
141
+ # `::JSON.parse(body, @parser_options || {})` — a positional options
142
+ # hash json 3.0 rejects, which forced a json < 3 pin on dependent
143
+ # bundles. Bodies arrive raw; lutaml-hal parses them with yeptris.
70
144
  conn.adapter Faraday.default_adapter
71
145
  end
72
146
  end
73
147
 
74
- # Retry policy for the W3C-specific transient failures (HTTP 403 and
75
- # connection/timeout). Grows 1, 2, 4, 8, 16s, matching rate_limiting_options.
148
+ # Retry policy for the W3C-specific transient failures. See
149
+ # DEFAULT_RETRY_OPTIONS for why `exceptions` and `max_interval` are what
150
+ # they are -- both have non-obvious failure modes.
76
151
  def retry_options
77
- {
78
- max: 5,
79
- interval: 1.0,
80
- backoff_factor: 2,
81
- max_interval: 30.0,
82
- retry_statuses: [403],
83
- exceptions: [
84
- Errno::ETIMEDOUT, Timeout::Error,
85
- Faraday::TimeoutError, Faraday::ConnectionFailed
86
- ],
87
- }
152
+ # dup: DEFAULT_RETRY_OPTIONS is frozen and must stay the pristine baseline.
153
+ @retry_options ||= DEFAULT_RETRY_OPTIONS.dup
154
+ end
155
+
156
+ # Set retry options (merged into the current ones)
157
+ #
158
+ # The retry middleware is baked into the Faraday connection when the
159
+ # connection is built, so this goes through the usual
160
+ # reset_connection -> reset_client -> rebuild_register cascade. Like the
161
+ # other configure_* setters it starts a fresh object cache, so it is a
162
+ # start-up knob rather than a mid-crawl one.
163
+ #
164
+ # There is no separate disable switch -- `configure_retry(max: 0)` turns
165
+ # retrying off, returning the response as-is on the first attempt.
166
+ def configure_retry(options = {})
167
+ @retry_options = retry_options.merge(options)
168
+ reset_connection
88
169
  end
89
170
 
90
171
  # Configure rate limiting options
@@ -106,8 +187,10 @@ module W3cApi
106
187
  # Set rate limiting options
107
188
  def configure_rate_limiting(options = {})
108
189
  @rate_limiting_options = rate_limiting_options.merge(options)
109
- # Reset client to pick up new options
110
- @client = nil
190
+ # Reset the client *and* the register to pick up the new options: the
191
+ # register keeps the client it was built with, so dropping @client alone
192
+ # left an already-built register on the old rate-limiting settings.
193
+ reset_client
111
194
  end
112
195
 
113
196
  # Disable rate limiting
@@ -136,19 +219,19 @@ module W3cApi
136
219
  # Set cache options (merged into the current ones)
137
220
  def configure_cache(options = {})
138
221
  @cache_options = (cache_options || {}).merge(options)
139
- reset_register
222
+ rebuild_register
140
223
  end
141
224
 
142
225
  # Disable caching of realized objects
143
226
  def disable_cache
144
227
  @cache_options = nil
145
- reset_register
228
+ rebuild_register
146
229
  end
147
230
 
148
231
  # Enable caching of realized objects
149
232
  def enable_cache(options = nil)
150
233
  @cache_options = options || { adapter: :memory }
151
- reset_register
234
+ rebuild_register
152
235
  end
153
236
 
154
237
  def register
@@ -167,6 +250,30 @@ module W3cApi
167
250
  @register
168
251
  end
169
252
 
253
+ # Drop the memoized Faraday connection. Memoization runs
254
+ # register -> client -> connection, so a connection-level change (headers,
255
+ # middleware) only takes effect once all three are rebuilt.
256
+ def reset_connection
257
+ @connection = nil
258
+ reset_client
259
+ end
260
+
261
+ # Drop the memoized lutaml-hal client. The register holds the client it was
262
+ # constructed with (and hands it to its cache manager), so it must go too.
263
+ def reset_client
264
+ @client = nil
265
+ rebuild_register
266
+ end
267
+
268
+ # Rebuild the register immediately rather than lazily. Link#realize looks
269
+ # the register up in lutaml-hal's GlobalRegister, which *raises* when the
270
+ # name is absent — so leaving it torn down would break `.realize` on every
271
+ # model fetched before a configure_* call.
272
+ def rebuild_register
273
+ reset_register
274
+ register
275
+ end
276
+
170
277
  def reset_register
171
278
  # Drop the global registration too, otherwise rebuilding the register
172
279
  # raises "replacing another one" when it re-registers the same name.
@@ -176,6 +283,17 @@ module W3cApi
176
283
 
177
284
  private
178
285
 
286
+ # Header values cannot contain CR/LF, and the value can come from an
287
+ # environment variable or a CLI flag — fold them to spaces rather than let
288
+ # the adapter raise deep inside a request. Blank becomes nil so the caller
289
+ # falls back to the environment or the default.
290
+ def normalize_user_agent(value)
291
+ return nil if value.nil?
292
+
293
+ normalized = value.to_s.gsub(/[\r\n]+/, " ").strip
294
+ normalized.empty? ? nil : normalized
295
+ end
296
+
179
297
  # Common pagination parameters (simplified without EndpointParameter)
180
298
  def pagination_parameters
181
299
  [
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module W3cApi
4
- VERSION = "0.3.2"
4
+ VERSION = "0.3.4"
5
5
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: w3c_api
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.2
4
+ version: 0.3.4
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ribose Inc.
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-06-03 00:00:00.000000000 Z
11
+ date: 2026-10-30 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: faraday
@@ -154,6 +154,7 @@ files:
154
154
  - lib/w3c_api/commands/specification_version.rb
155
155
  - lib/w3c_api/commands/translation.rb
156
156
  - lib/w3c_api/commands/user.rb
157
+ - lib/w3c_api/commands/user_agent_option.rb
157
158
  - lib/w3c_api/embed.rb
158
159
  - lib/w3c_api/hal.rb
159
160
  - lib/w3c_api/models.rb