w3c_api 0.3.2 → 0.3.3

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: a1fa4a04e2e21f619281c54f4722ab649a430667aec4ffa5417d94ffdd5594b9
4
+ data.tar.gz: e59116fe79cd6dd32a6fba9bdc01f54f1f6256d406ca079af63bf0ecd443de4e
5
5
  SHA512:
6
- metadata.gz: 57a1d8396a7bba59cff32430641bf90a75a251f9b8ea227710ec6e27bd4fc1fef2d5d9c667bfb7e5a4c6782e099ad96ddd72060e1c9973fbe9c7068b223b4845
7
- data.tar.gz: b0178bf92b0e73a16d226aeeb0c91f0cee3ca3b91857a7b6241ff2423a604ca62181c9edb76af63833b875cf314842ac572badb9dd158a8bcbb172612a21bf03
6
+ metadata.gz: 460db146f8185a32c6763bd57a11017042ce34b8d1784c8fdec2f4c4d3f156699ca2a0af3125c505c8989bb94eb284bcb0fd3ddfd70b4e764f380b792e7fc2fc
7
+ data.tar.gz: 64b7e45b394e78479a29c7a940fb61cb816c9cae806ce7042fb4647975509229872f7d680c600d67ca1e65502aa4b3809c4eba3f844395e40c2ab3f17b71db7b
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,13 +103,38 @@ 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
140
  conn.request :json
@@ -71,20 +143,27 @@ module W3cApi
71
143
  end
72
144
  end
73
145
 
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.
146
+ # Retry policy for the W3C-specific transient failures. See
147
+ # DEFAULT_RETRY_OPTIONS for why `exceptions` and `max_interval` are what
148
+ # they are -- both have non-obvious failure modes.
76
149
  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
- }
150
+ # dup: DEFAULT_RETRY_OPTIONS is frozen and must stay the pristine baseline.
151
+ @retry_options ||= DEFAULT_RETRY_OPTIONS.dup
152
+ end
153
+
154
+ # Set retry options (merged into the current ones)
155
+ #
156
+ # The retry middleware is baked into the Faraday connection when the
157
+ # connection is built, so this goes through the usual
158
+ # reset_connection -> reset_client -> rebuild_register cascade. Like the
159
+ # other configure_* setters it starts a fresh object cache, so it is a
160
+ # start-up knob rather than a mid-crawl one.
161
+ #
162
+ # There is no separate disable switch -- `configure_retry(max: 0)` turns
163
+ # retrying off, returning the response as-is on the first attempt.
164
+ def configure_retry(options = {})
165
+ @retry_options = retry_options.merge(options)
166
+ reset_connection
88
167
  end
89
168
 
90
169
  # Configure rate limiting options
@@ -106,8 +185,10 @@ module W3cApi
106
185
  # Set rate limiting options
107
186
  def configure_rate_limiting(options = {})
108
187
  @rate_limiting_options = rate_limiting_options.merge(options)
109
- # Reset client to pick up new options
110
- @client = nil
188
+ # Reset the client *and* the register to pick up the new options: the
189
+ # register keeps the client it was built with, so dropping @client alone
190
+ # left an already-built register on the old rate-limiting settings.
191
+ reset_client
111
192
  end
112
193
 
113
194
  # Disable rate limiting
@@ -136,19 +217,19 @@ module W3cApi
136
217
  # Set cache options (merged into the current ones)
137
218
  def configure_cache(options = {})
138
219
  @cache_options = (cache_options || {}).merge(options)
139
- reset_register
220
+ rebuild_register
140
221
  end
141
222
 
142
223
  # Disable caching of realized objects
143
224
  def disable_cache
144
225
  @cache_options = nil
145
- reset_register
226
+ rebuild_register
146
227
  end
147
228
 
148
229
  # Enable caching of realized objects
149
230
  def enable_cache(options = nil)
150
231
  @cache_options = options || { adapter: :memory }
151
- reset_register
232
+ rebuild_register
152
233
  end
153
234
 
154
235
  def register
@@ -167,6 +248,30 @@ module W3cApi
167
248
  @register
168
249
  end
169
250
 
251
+ # Drop the memoized Faraday connection. Memoization runs
252
+ # register -> client -> connection, so a connection-level change (headers,
253
+ # middleware) only takes effect once all three are rebuilt.
254
+ def reset_connection
255
+ @connection = nil
256
+ reset_client
257
+ end
258
+
259
+ # Drop the memoized lutaml-hal client. The register holds the client it was
260
+ # constructed with (and hands it to its cache manager), so it must go too.
261
+ def reset_client
262
+ @client = nil
263
+ rebuild_register
264
+ end
265
+
266
+ # Rebuild the register immediately rather than lazily. Link#realize looks
267
+ # the register up in lutaml-hal's GlobalRegister, which *raises* when the
268
+ # name is absent — so leaving it torn down would break `.realize` on every
269
+ # model fetched before a configure_* call.
270
+ def rebuild_register
271
+ reset_register
272
+ register
273
+ end
274
+
170
275
  def reset_register
171
276
  # Drop the global registration too, otherwise rebuilding the register
172
277
  # raises "replacing another one" when it re-registers the same name.
@@ -176,6 +281,17 @@ module W3cApi
176
281
 
177
282
  private
178
283
 
284
+ # Header values cannot contain CR/LF, and the value can come from an
285
+ # environment variable or a CLI flag — fold them to spaces rather than let
286
+ # the adapter raise deep inside a request. Blank becomes nil so the caller
287
+ # falls back to the environment or the default.
288
+ def normalize_user_agent(value)
289
+ return nil if value.nil?
290
+
291
+ normalized = value.to_s.gsub(/[\r\n]+/, " ").strip
292
+ normalized.empty? ? nil : normalized
293
+ end
294
+
179
295
  # Common pagination parameters (simplified without EndpointParameter)
180
296
  def pagination_parameters
181
297
  [
@@ -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.3"
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.3
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-08-20 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