relaton 3.0.0.pre.alpha.1 → 3.0.0.pre.alpha.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.
Files changed (270) hide show
  1. checksums.yaml +4 -4
  2. data/README.adoc +57 -1
  3. data/bin/console +0 -1
  4. data/lib/relaton/3gpp/bibliography.rb +82 -7
  5. data/lib/relaton/3gpp/data_fetcher.rb +51 -3
  6. data/lib/relaton/3gpp/docidentifier.rb +114 -0
  7. data/lib/relaton/3gpp/item.rb +6 -0
  8. data/lib/relaton/3gpp/parser.rb +1 -1
  9. data/lib/relaton/3gpp/processor.rb +4 -1
  10. data/lib/relaton/3gpp.rb +5 -1
  11. data/lib/relaton/adobe/bibdata.rb +8 -0
  12. data/lib/relaton/adobe/bibitem.rb +8 -0
  13. data/lib/relaton/adobe/bibliography.rb +92 -0
  14. data/lib/relaton/adobe/docidentifier.rb +49 -0
  15. data/lib/relaton/adobe/doctype.rb +14 -0
  16. data/lib/relaton/adobe/ext.rb +32 -0
  17. data/lib/relaton/adobe/item.rb +15 -0
  18. data/lib/relaton/adobe/item_base.rb +18 -0
  19. data/lib/relaton/adobe/item_data.rb +6 -0
  20. data/lib/relaton/adobe/processor.rb +45 -0
  21. data/lib/relaton/adobe/util.rb +8 -0
  22. data/lib/relaton/adobe.rb +37 -0
  23. data/lib/relaton/bib/model/address.rb +2 -2
  24. data/lib/relaton/bib/model/docidentifier.rb +24 -9
  25. data/lib/relaton/bib/model/localized_string.rb +1 -1
  26. data/lib/relaton/bib/model/structured_identifier.rb +10 -9
  27. data/lib/relaton/bib/sanitizer.rb +202 -6
  28. data/lib/relaton/bib.rb +0 -2
  29. data/lib/relaton/bipm/bibliography.rb +159 -10
  30. data/lib/relaton/bipm/data_fetcher.rb +26 -2
  31. data/lib/relaton/bipm/data_outcomes_parser.rb +3 -4
  32. data/lib/relaton/bipm/id_parser.rb +5 -4
  33. data/lib/relaton/bipm/model/structured_identifier.rb +21 -0
  34. data/lib/relaton/bipm/processor.rb +2 -2
  35. data/lib/relaton/bipm/rawdata_bipm_metrologia/fetcher.rb +2 -4
  36. data/lib/relaton/bipm/si_brochure_parser.rb +5 -3
  37. data/lib/relaton/bipm.rb +7 -1
  38. data/lib/relaton/bsi/bibliography.rb +115 -43
  39. data/lib/relaton/bsi/hit.rb +14 -0
  40. data/lib/relaton/bsi/hit_collection.rb +15 -16
  41. data/lib/relaton/bsi/model/docidentifier.rb +99 -1
  42. data/lib/relaton/bsi/processor.rb +1 -0
  43. data/lib/relaton/calconnect/bibliography.rb +12 -14
  44. data/lib/relaton/calconnect/data_fetcher.rb +77 -9
  45. data/lib/relaton/calconnect/docidentifier.rb +80 -0
  46. data/lib/relaton/calconnect/hit_collection.rb +65 -57
  47. data/lib/relaton/calconnect/model/item.rb +7 -0
  48. data/lib/relaton/calconnect/processor.rb +7 -1
  49. data/lib/relaton/calconnect.rb +11 -1
  50. data/lib/relaton/ccsds/data/fetcher.rb +17 -12
  51. data/lib/relaton/ccsds/data/parser.rb +1 -1
  52. data/lib/relaton/ccsds/hit_collection.rb +6 -1
  53. data/lib/relaton/ccsds/model/docidentifier.rb +121 -0
  54. data/lib/relaton/ccsds/model/item.rb +2 -0
  55. data/lib/relaton/cen/bibliography.rb +75 -47
  56. data/lib/relaton/cen/hit.rb +16 -1
  57. data/lib/relaton/cen/hit_collection.rb +59 -11
  58. data/lib/relaton/cen/model/docidentifier.rb +92 -1
  59. data/lib/relaton/cen/processor.rb +13 -8
  60. data/lib/relaton/cen/scraper.rb +13 -5
  61. data/lib/relaton/cen.rb +1 -0
  62. data/lib/relaton/cie/data_fetcher.rb +215 -30
  63. data/lib/relaton/cie/processor.rb +3 -1
  64. data/lib/relaton/cie/scrapper.rb +15 -2
  65. data/lib/relaton/cie.rb +2 -1
  66. data/lib/relaton/core/data_fetcher.rb +150 -3
  67. data/lib/relaton/core/governor.rb +320 -0
  68. data/lib/relaton/core/pacer.rb +134 -0
  69. data/lib/relaton/core/processor.rb +19 -0
  70. data/lib/relaton/core/request_error.rb +14 -0
  71. data/lib/relaton/core.rb +3 -0
  72. data/lib/relaton/db/registry.rb +41 -1
  73. data/lib/relaton/doi/crossref.rb +19 -2
  74. data/lib/relaton/doi/parser.rb +109 -15
  75. data/lib/relaton/easc/bibdata.rb +8 -0
  76. data/lib/relaton/easc/bibitem.rb +8 -0
  77. data/lib/relaton/easc/bibliography.rb +95 -0
  78. data/lib/relaton/easc/docidentifier.rb +100 -0
  79. data/lib/relaton/easc/doctype.rb +14 -0
  80. data/lib/relaton/easc/ext.rb +44 -0
  81. data/lib/relaton/easc/item.rb +13 -0
  82. data/lib/relaton/easc/item_base.rb +18 -0
  83. data/lib/relaton/easc/item_data.rb +6 -0
  84. data/lib/relaton/easc/processor.rb +46 -0
  85. data/lib/relaton/easc/util.rb +8 -0
  86. data/lib/relaton/easc.rb +35 -0
  87. data/lib/relaton/ecma/bibliography.rb +93 -25
  88. data/lib/relaton/ecma/data_fetcher.rb +71 -12
  89. data/lib/relaton/ecma/docidentifier.rb +124 -0
  90. data/lib/relaton/ecma/item.rb +2 -0
  91. data/lib/relaton/ecma/memento_parser.rb +1 -1
  92. data/lib/relaton/ecma/page_fetcher.rb +15 -3
  93. data/lib/relaton/ecma/parser_common.rb +2 -2
  94. data/lib/relaton/ecma/processor.rb +4 -1
  95. data/lib/relaton/ecma/standard_parser.rb +2 -2
  96. data/lib/relaton/ecma.rb +10 -1
  97. data/lib/relaton/etsi/bibliography.rb +67 -2
  98. data/lib/relaton/etsi/data_fetcher.rb +43 -4
  99. data/lib/relaton/etsi/processor.rb +3 -1
  100. data/lib/relaton/etsi.rb +2 -1
  101. data/lib/relaton/gb/bibliography.rb +55 -29
  102. data/lib/relaton/gb/docidentifier.rb +58 -9
  103. data/lib/relaton/gb/processor.rb +3 -0
  104. data/lib/relaton/gb/scraper.rb +27 -10
  105. data/lib/relaton/gost/bibdata.rb +8 -0
  106. data/lib/relaton/gost/bibitem.rb +8 -0
  107. data/lib/relaton/gost/bibliography.rb +107 -0
  108. data/lib/relaton/gost/docidentifier.rb +80 -0
  109. data/lib/relaton/gost/doctype.rb +16 -0
  110. data/lib/relaton/gost/ext.rb +46 -0
  111. data/lib/relaton/gost/item.rb +15 -0
  112. data/lib/relaton/gost/item_base.rb +18 -0
  113. data/lib/relaton/gost/item_data.rb +6 -0
  114. data/lib/relaton/gost/processor.rb +49 -0
  115. data/lib/relaton/gost/util.rb +8 -0
  116. data/lib/relaton/gost.rb +36 -0
  117. data/lib/relaton/iala/bibdata.rb +8 -0
  118. data/lib/relaton/iala/bibitem.rb +8 -0
  119. data/lib/relaton/iala/bibliography.rb +146 -0
  120. data/lib/relaton/iala/docidentifier.rb +89 -0
  121. data/lib/relaton/iala/doctype.rb +18 -0
  122. data/lib/relaton/iala/ext.rb +32 -0
  123. data/lib/relaton/iala/item.rb +21 -0
  124. data/lib/relaton/iala/item_base.rb +18 -0
  125. data/lib/relaton/iala/item_data.rb +6 -0
  126. data/lib/relaton/iala/processor.rb +43 -0
  127. data/lib/relaton/iala/relation.rb +7 -0
  128. data/lib/relaton/iala/util.rb +8 -0
  129. data/lib/relaton/iala.rb +35 -0
  130. data/lib/relaton/iana/bibliography.rb +67 -14
  131. data/lib/relaton/iana/data_fetcher.rb +35 -5
  132. data/lib/relaton/iana/processor.rb +3 -1
  133. data/lib/relaton/iana.rb +12 -1
  134. data/lib/relaton/iec/data_fetcher.rb +7 -1
  135. data/lib/relaton/iec/hit_collection.rb +1 -1
  136. data/lib/relaton/iec/model/docidentifier.rb +9 -5
  137. data/lib/relaton/iec/model/ext.rb +2 -2
  138. data/lib/relaton/iec/processor.rb +1 -0
  139. data/lib/relaton/ieee/bibliography.rb +25 -3
  140. data/lib/relaton/ieee/data_fetcher.rb +158 -17
  141. data/lib/relaton/ieee/idams_parser.rb +18 -11
  142. data/lib/relaton/ieee/processor.rb +4 -1
  143. data/lib/relaton/ieee/rawbib_id_parser.rb +291 -86
  144. data/lib/relaton/ieee.rb +2 -1
  145. data/lib/relaton/ietf/data_fetcher.rb +295 -12
  146. data/lib/relaton/ietf/processor.rb +7 -3
  147. data/lib/relaton/ietf/rfc/entry.rb +39 -3
  148. data/lib/relaton/ietf/scraper.rb +69 -36
  149. data/lib/relaton/ietf.rb +4 -1
  150. data/lib/relaton/iho/bibliography.rb +1 -1
  151. data/lib/relaton/iho/docidentifier.rb +1 -1
  152. data/lib/relaton/index/file_io.rb +11 -11
  153. data/lib/relaton/index/file_storage.rb +6 -1
  154. data/lib/relaton/index/pool.rb +6 -1
  155. data/lib/relaton/index/shard_source.rb +201 -0
  156. data/lib/relaton/index/type.rb +63 -12
  157. data/lib/relaton/index.rb +2 -1
  158. data/lib/relaton/iso/bibliography.rb +20 -15
  159. data/lib/relaton/iso/data_fetcher.rb +3 -3
  160. data/lib/relaton/iso/data_parser.rb +17 -3
  161. data/lib/relaton/iso/hit_collection.rb +27 -15
  162. data/lib/relaton/iso/item_data.rb +22 -0
  163. data/lib/relaton/iso/model/docidentifier.rb +24 -12
  164. data/lib/relaton/iso/processor.rb +1 -0
  165. data/lib/relaton/iso/scraper.rb +19 -3
  166. data/lib/relaton/itu/bibliography.rb +9 -4
  167. data/lib/relaton/itu/data_crawler_r.rb +664 -0
  168. data/lib/relaton/itu/data_fetcher.rb +496 -50
  169. data/lib/relaton/itu/data_merge_r.rb +149 -0
  170. data/lib/relaton/itu/data_parser_r.rb +163 -89
  171. data/lib/relaton/itu/data_parser_t.rb +228 -0
  172. data/lib/relaton/itu/family_cache.rb +177 -0
  173. data/lib/relaton/itu/governor.rb +56 -0
  174. data/lib/relaton/itu/hit.rb +9 -3
  175. data/lib/relaton/itu/hit_collection.rb +258 -86
  176. data/lib/relaton/itu/model/docidentifier.rb +67 -1
  177. data/lib/relaton/itu/model/structured_identifier.rb +19 -0
  178. data/lib/relaton/itu/processor.rb +10 -4
  179. data/lib/relaton/itu/pubid.rb +27 -5
  180. data/lib/relaton/itu/recommendation_fields.rb +334 -0
  181. data/lib/relaton/itu/recommendation_parser.rb +18 -149
  182. data/lib/relaton/itu/scraper.rb +13 -3
  183. data/lib/relaton/itu.rb +2 -1
  184. data/lib/relaton/jcgm/bibdata.rb +8 -0
  185. data/lib/relaton/jcgm/bibitem.rb +8 -0
  186. data/lib/relaton/jcgm/bibliography.rb +97 -0
  187. data/lib/relaton/jcgm/data_fetcher.rb +81 -0
  188. data/lib/relaton/jcgm/docidentifier.rb +102 -0
  189. data/lib/relaton/jcgm/doctype.rb +12 -0
  190. data/lib/relaton/jcgm/ext.rb +23 -0
  191. data/lib/relaton/jcgm/item.rb +20 -0
  192. data/lib/relaton/jcgm/item_base.rb +18 -0
  193. data/lib/relaton/jcgm/item_data.rb +6 -0
  194. data/lib/relaton/jcgm/meetings_parser.rb +175 -0
  195. data/lib/relaton/jcgm/processor.rb +71 -0
  196. data/lib/relaton/jcgm/relation.rb +9 -0
  197. data/lib/relaton/jcgm/structured_identifier.rb +40 -0
  198. data/lib/relaton/jcgm/util.rb +8 -0
  199. data/lib/relaton/jcgm.rb +24 -0
  200. data/lib/relaton/jis/bibliography.rb +8 -10
  201. data/lib/relaton/jis/data_fetcher.rb +21 -19
  202. data/lib/relaton/jis/docidentifier.rb +104 -5
  203. data/lib/relaton/jis/hit.rb +18 -23
  204. data/lib/relaton/jis/hit_collection.rb +19 -18
  205. data/lib/relaton/jis/processor.rb +1 -1
  206. data/lib/relaton/jis.rb +2 -3
  207. data/lib/relaton/logger/channels/gh_issue.rb +78 -13
  208. data/lib/relaton/nist/data_fetcher.rb +63 -13
  209. data/lib/relaton/nist/docidentifier.rb +165 -0
  210. data/lib/relaton/nist/item.rb +2 -0
  211. data/lib/relaton/nist/item_base.rb +16 -0
  212. data/lib/relaton/nist/mods_parser.rb +38 -12
  213. data/lib/relaton/nist/processor.rb +2 -1
  214. data/lib/relaton/nist/relation.rb +3 -0
  215. data/lib/relaton/nist/scraper.rb +6 -3
  216. data/lib/relaton/oasis/bibliography.rb +147 -6
  217. data/lib/relaton/oasis/data_fetcher.rb +41 -5
  218. data/lib/relaton/oasis/data_parser_utils.rb +37 -3
  219. data/lib/relaton/oasis/docidentifier.rb +54 -0
  220. data/lib/relaton/oasis/item.rb +3 -0
  221. data/lib/relaton/oasis/processor.rb +7 -1
  222. data/lib/relaton/oasis.rb +14 -1
  223. data/lib/relaton/ogc/data_fetcher.rb +23 -2
  224. data/lib/relaton/ogc/docidentifier.rb +105 -0
  225. data/lib/relaton/ogc/hit_collection.rb +78 -3
  226. data/lib/relaton/ogc/processor.rb +2 -1
  227. data/lib/relaton/ogc.rb +5 -1
  228. data/lib/relaton/oiml/bibliography.rb +90 -15
  229. data/lib/relaton/oiml/docidentifier.rb +18 -3
  230. data/lib/relaton/omg/docidentifier.rb +67 -0
  231. data/lib/relaton/omg/item.rb +1 -0
  232. data/lib/relaton/omg/processor.rb +1 -0
  233. data/lib/relaton/omg/scraper.rb +61 -16
  234. data/lib/relaton/omg.rb +1 -0
  235. data/lib/relaton/plateau/bibliography.rb +10 -3
  236. data/lib/relaton/plateau/data_fetcher.rb +25 -2
  237. data/lib/relaton/plateau/handbook_parser.rb +8 -1
  238. data/lib/relaton/plateau/hit.rb +10 -2
  239. data/lib/relaton/plateau/hit_collection.rb +31 -11
  240. data/lib/relaton/plateau/processor.rb +3 -1
  241. data/lib/relaton/plateau/technical_report_parser.rb +8 -1
  242. data/lib/relaton/plateau.rb +2 -1
  243. data/lib/relaton/sdo/config.rb +34 -0
  244. data/lib/relaton/sdo/fetcher.rb +52 -0
  245. data/lib/relaton/sdo/logo.rb +95 -0
  246. data/lib/relaton/sdo/name.rb +26 -0
  247. data/lib/relaton/sdo/organization.rb +71 -0
  248. data/lib/relaton/sdo/store.rb +49 -0
  249. data/lib/relaton/sdo.rb +29 -0
  250. data/lib/relaton/version.rb +1 -1
  251. data/lib/relaton/w3c/bibliography.rb +132 -12
  252. data/lib/relaton/w3c/data_fetcher.rb +194 -16
  253. data/lib/relaton/w3c/data_parser.rb +3 -3
  254. data/lib/relaton/w3c/docidentifier.rb +48 -0
  255. data/lib/relaton/w3c/governor.rb +32 -0
  256. data/lib/relaton/w3c/item.rb +3 -0
  257. data/lib/relaton/w3c/pubid.rb +12 -0
  258. data/lib/relaton/w3c/safe_realize.rb +110 -21
  259. data/lib/relaton/w3c.rb +12 -1
  260. data/lib/relaton/xsf/bibliography.rb +61 -1
  261. data/lib/relaton/xsf/data_fetcher.rb +55 -5
  262. data/lib/relaton/xsf/docidentifier.rb +46 -0
  263. data/lib/relaton/xsf/hit_collection.rb +31 -3
  264. data/lib/relaton/xsf/item.rb +6 -0
  265. data/lib/relaton/xsf/processor.rb +1 -0
  266. data/lib/relaton/xsf.rb +5 -1
  267. data/lib/relaton.rb +42 -0
  268. metadata +135 -24
  269. data/lib/relaton/ieee/pub_id.rb +0 -161
  270. data/lib/relaton/index/id_number.rb +0 -30
@@ -3,7 +3,6 @@ require "w3c_api"
3
3
  require_relative "../w3c"
4
4
  require_relative "safe_realize"
5
5
  require_relative "data_parser"
6
- require_relative "pubid"
7
6
 
8
7
  module Relaton
9
8
  module W3c
@@ -27,6 +26,48 @@ module Relaton
27
26
  # (rate-limit/connection) before giving up and aborting the crawl.
28
27
  PAGE_FETCH_ATTEMPTS = 3
29
28
 
29
+ # How many resources may be lost to rate limiting before the dataset is
30
+ # considered too incomplete to save. Deliberately low: crawls are bimodal
31
+ # — a healthy run loses none (Aug 17 2026: 0), a rate-limited one loses
32
+ # hundreds (Aug 19 2026: 1,412) — so there is no middle ground to protect.
33
+ # Genuinely broken resources (permanent skips from 404s/5xx) are counted
34
+ # separately and stay tolerated.
35
+ DEFAULT_MAX_THROTTLED_LOSSES = 25
36
+
37
+ # Retry policy pushed into w3c_api. Its defaults spend ~31s (1+2+4+8+16)
38
+ # inside lutaml-hal before a 429 ever reaches us, which just delays the
39
+ # shared back-pressure that actually works; this compresses the same chain
40
+ # to ~11s (0.5+1+2+4+4) so the Governor can open its pool-wide cooldown
41
+ # while the ban window is still young.
42
+ #
43
+ # `max_retries` deliberately stays at 5. lutaml-hal applies one retry count
44
+ # to both 429 and 5xx, and unlike a 429 a 5xx is still a *permanent* skip
45
+ # here — cutting its attempts would blacklist a burst of resources on a
46
+ # brief upstream wobble that the old policy rode out, and `skipped` is not
47
+ # counted by guard_throttle_budget, so that loss would be invisible.
48
+ #
49
+ # `max_retry_after` matches `max_delay` because a server-instructed wait
50
+ # must not outlive the compressed chain either. Left unset, lutaml-hal's
51
+ # DEFAULT_MAX_RETRY_AFTER (300 s) applies, and a large Retry-After on a
52
+ # 429 parks one thread inside the HTTP layer for up to 5 x 300 s with the
53
+ # governor hearing nothing — the very thing the compression prevents. The
54
+ # hint is not discarded, it moves to where it belongs: Governor.retry_after
55
+ # reads it off the same error and uses it as the FLOOR of the pool-wide
56
+ # cooldown, under the governor's own 900 s cap.
57
+ UPSTREAM_RATE_LIMITING = {
58
+ max_retries: 5, base_delay: 0.5, max_delay: 4.0, backoff_factor: 2.0,
59
+ max_retry_after: 4.0
60
+ }.freeze
61
+
62
+ # Ceiling on resources dropped to rate limiting before the crawl refuses
63
+ # to save. Tunable via env var for a deliberately partial run; 0 is a
64
+ # meaningful setting (tolerate nothing), so it is accepted here unlike in
65
+ # the governor's duration knobs.
66
+ def self.max_throttled_losses
67
+ value = ENV["RELATON_W3C_MAX_THROTTLED"].to_s.strip
68
+ value.match?(/\A\d+\z/) ? value.to_i : DEFAULT_MAX_THROTTLED_LOSSES
69
+ end
70
+
30
71
  # Number of fetch_spec worker threads. Tunable via env var so CI or
31
72
  # local runs can dial it up for speed or down to lighten load on
32
73
  # api.w3.org (or for debugging).
@@ -52,8 +93,14 @@ module Relaton
52
93
  @interrupted = false
53
94
  end
54
95
 
96
+ # The index is pubid-backed: `pubid_class:` makes Relaton::Index sort the
97
+ # rows by `id.root.number` on save and serialize each id to its
98
+ # `_type: pubid:w3c:*` hash. Without it a v1-shaped file would be written
99
+ # under a v2 name, with no error and no narrowing.
55
100
  def index
56
- @index ||= Relaton::Index.find_or_create(:W3C, file: "#{INDEXFILE}.yaml")
101
+ @index ||= Relaton::Index.find_or_create(
102
+ :W3C, file: "#{INDEXFILE}.yaml", pubid_class: ::Pubid::W3c::Identifier
103
+ )
57
104
  end
58
105
 
59
106
  def log_error(msg)
@@ -75,6 +122,8 @@ module Relaton
75
122
  # of everything fetched so far is saved rather than the run being lost.
76
123
  #
77
124
  def fetch(_source = nil)
125
+ SafeRealize.reset!
126
+ configure_upstream_rate_limiting
78
127
  n_workers = self.class.concurrency
79
128
  queue = SizedQueue.new(n_workers * 4)
80
129
  workers = Array.new(n_workers) { spawn_worker(queue) }
@@ -89,13 +138,52 @@ module Relaton
89
138
  n_workers.times { queue << nil } # poison pills
90
139
  workers.each(&:join)
91
140
  end
92
- Util.warn "Crawl interrupted — saving progress collected so far." if @interrupted
141
+ if @interrupted
142
+ # Ctrl-C means "give me what you have", so the completeness guards
143
+ # below are deliberately not applied to an interrupted crawl.
144
+ Util.warn "Crawl interrupted — saving progress collected so far."
145
+ else
146
+ guard_rate_limited
147
+ guard_throttle_budget
148
+ end
93
149
  index.save
94
150
  end
95
151
 
96
152
  report_errors
97
153
  end
98
154
 
155
+ # Abort when the governor concluded we are banned rather than briefly
156
+ # throttled. Everything fetched so far is discarded on purpose: a partial
157
+ # dataset must never be saved (crawler.rb wipes data/ first, so saving one
158
+ # commits mass deletions).
159
+ def guard_rate_limited
160
+ return unless SafeRealize.governor.exhausted?
161
+
162
+ # #give_up_rounds, not #throttle_rounds: the latter is live and a
163
+ # straggler success resets it, which is how a give-up at round 5 got
164
+ # reported as "after 2 consecutive backoffs".
165
+ rounds = SafeRealize.governor.give_up_rounds ||
166
+ SafeRealize.governor.throttle_rounds
167
+ raise CrawlIncompleteError,
168
+ "crawl is rate-limited: gave up after " \
169
+ "#{rounds} consecutive backoffs " \
170
+ "(#{SafeRealize.governor.throttle_count} rate-limited responses); " \
171
+ "refusing to save a partial dataset"
172
+ end
173
+
174
+ # The per-resource counterpart to guard_complete_pagination: pagination can
175
+ # complete cleanly while rate limiting has quietly hollowed out the
176
+ # documents behind it.
177
+ def guard_throttle_budget
178
+ lost = SafeRealize.throttled.size
179
+ budget = self.class.max_throttled_losses
180
+ return if lost <= budget
181
+
182
+ raise CrawlIncompleteError,
183
+ "#{lost} resources were dropped to rate limiting (budget #{budget}); " \
184
+ "refusing to save a partial dataset"
185
+ end
186
+
99
187
  #
100
188
  # Page through the specifications index, feeding each spec (paired with
101
189
  # its embedded page) to the worker queue. Returns early when interrupted.
@@ -113,11 +201,11 @@ module Relaton
113
201
  loop do
114
202
  page = specs
115
203
  page.links.specifications.each do |spec|
116
- break if @interrupted
204
+ break if stopping?
117
205
 
118
206
  queue << [spec, page]
119
207
  end
120
- break if @interrupted
208
+ break if stopping?
121
209
 
122
210
  last_page = page.page
123
211
  break unless page.next?
@@ -133,6 +221,11 @@ module Relaton
133
221
  # `break`ing prevents a rate-limit blip from silently truncating the
134
222
  # dataset: a partial crawl must never be saved/committed.
135
223
  unless next_page
224
+ # When the governor gave up, the page didn't fail — we stopped
225
+ # asking. Let guard_rate_limited report that with the accurate
226
+ # reason instead of dressing it up as a pagination fault.
227
+ break if SafeRealize.governor.exhausted?
228
+
136
229
  raise CrawlIncompleteError,
137
230
  "specifications pagination stopped at page #{page.page}: " \
138
231
  "failed to fetch page #{page.page + 1}"
@@ -141,11 +234,29 @@ module Relaton
141
234
  specs = next_page
142
235
  end
143
236
 
144
- return if @interrupted
237
+ return if stopping?
145
238
 
146
239
  guard_complete_pagination(last_page, expected_pages)
147
240
  end
148
241
 
242
+ # Reasons to stop feeding the queue: a Ctrl-C (save what we have) or the
243
+ # governor concluding the crawl is banned (abort). Both need the producer
244
+ # to stop; #fetch decides which of the two it was.
245
+ def stopping?
246
+ @interrupted || SafeRealize.governor.exhausted?
247
+ end
248
+
249
+ # The mid-spec back-out, and deliberately NOT #stopping?. The two stop
250
+ # reasons want opposite things of a spec already in flight:
251
+ #
252
+ # - Ctrl-C means "give me what you have". The index IS saved, so the
253
+ # in-flight spec must finish, or a half-parsed document is written.
254
+ # - A latched give-up means the run is lost — guard_rate_limited raises
255
+ # and nothing is saved — and the host has banned us. Abandon at once.
256
+ def abandoned?
257
+ SafeRealize.governor.exhausted?
258
+ end
259
+
149
260
  # Defense in depth: even when no page fetch raised, make sure pagination
150
261
  # actually reached the last page the API advertised. Catches truncation
151
262
  # modes other than a failed fetch (e.g. a `next` link that goes missing).
@@ -164,6 +275,10 @@ module Relaton
164
275
  # parent_resource serves the spec from embedded data (no HTTP request).
165
276
  spec = realize(unrealized_spec, parent_resource: page)
166
277
  return unless spec
278
+ # The latch can land while this spec is in flight. Nothing this crawl
279
+ # produces is saved any more, so stop before the parse and the write
280
+ # too, not only before the requests.
281
+ return if abandoned?
167
282
 
168
283
  local_errors = Hash.new(true)
169
284
  save_doc DataParser.parse(spec, local_errors)
@@ -209,14 +324,41 @@ module Relaton
209
324
  if @files.include?(file)
210
325
  Util.warn "File #{file} already exists. Document: #{bib.docnumber}" if warn_duplicate
211
326
  else
212
- pubid = PubId.parse bib.docnumber
213
- index.add_or_update pubid.to_hash, file
327
+ index_primary bib.docidentifier&.detect(&:primary), file
214
328
  @files << file
215
329
  end
216
330
  File.write file, serialize(bib), encoding: "UTF-8"
217
331
  end
218
332
  end
219
333
 
334
+ #
335
+ # Add a document's primary id to the index.
336
+ #
337
+ # Every W3C id is expected to parse. One that does not is recorded in
338
+ # `@errors`, so the inherited `report_errors` logs it at ERROR and raises
339
+ # a tracked GitHub issue at the end of the crawl, and the row is skipped
340
+ # rather than indexed unparsed: `Relaton::Index` rejects the WHOLE index
341
+ # if a single row fails to deserialize, and its sort calls
342
+ # `.root.number` on every id. The data file is still written, so the
343
+ # document is unindexed, never lost.
344
+ #
345
+ # Keyed by the ID rather than the output path: the id is the defect, the
346
+ # file is only where it landed, and one broken id reaching several files
347
+ # must still report once.
348
+ #
349
+ # @param [Docidentifier, nil] docid the document's primary identifier
350
+ # @param [String] file path the document was written to
351
+ #
352
+ def index_primary(docid, file)
353
+ if docid&.pubid
354
+ index.add_or_update docid.pubid, file
355
+ return
356
+ end
357
+
358
+ id = docid&.content || file
359
+ @errors[id.to_s] = "Unparseable primary id `#{id}` was not indexed (#{file})"
360
+ end
361
+
220
362
  def to_xml(bib)
221
363
  bib.to_xml(bibdata: true)
222
364
  end
@@ -262,15 +404,30 @@ module Relaton
262
404
  # embedded_data is populated. Transient 403/5xx/connection failures are
263
405
  # already retried upstream (w3c_api/lutaml-hal), but losing an index page
264
406
  # drops every spec on it, so retry a few more times here with backoff to
265
- # ride out a brief rate-limit window. Returns nil only once the attempts
266
- # are exhausted; the caller turns that into a CrawlIncompleteError so the
267
- # crawl aborts instead of committing a truncated dataset.
407
+ # ride out a brief wobble. Rate limiting is handled separately, by the
408
+ # governor. Returns nil once the attempts are exhausted (or the crawl is
409
+ # declared banned); the caller turns that into a CrawlIncompleteError so
410
+ # the crawl aborts instead of committing a truncated dataset.
268
411
  def fetch_specifications_page(number)
269
412
  attempt = 0
270
413
  begin
271
- attempt += 1
272
- client.specifications(embed: true, page: number)
414
+ SafeRealize.governor.wait
415
+ page = client.specifications(embed: true, page: number)
416
+ SafeRealize.governor.succeeded!
417
+ page
418
+ rescue *Governor::THROTTLE_ERRORS => e
419
+ # How long to keep trying a rate-limited page is the governor's call,
420
+ # not this counter's: PAGE_FETCH_ATTEMPTS would give up after ~3
421
+ # minutes and report a *pagination* fault, hiding the real reason and
422
+ # pre-empting the escalation ladder. Retry until the governor declares
423
+ # the crawl banned; #wait above paces each attempt, and returns
424
+ # immediately once it has, so this cannot spin.
425
+ log_error "Rate-limited fetching specifications page #{number}: #{e.message}"
426
+ return nil if SafeRealize.governor.throttled!(retry_after: Governor.retry_after(e))
427
+
428
+ retry
273
429
  rescue Lutaml::Hal::Error, Faraday::Error => e
430
+ attempt += 1
274
431
  log_error "Failed to fetch specifications page #{number} " \
275
432
  "(attempt #{attempt}/#{PAGE_FETCH_ATTEMPTS}): " \
276
433
  "#{e.class}: #{e.message}"
@@ -282,12 +439,33 @@ module Relaton
282
439
  end
283
440
  end
284
441
 
442
+ # Point w3c_api's rate limiter at UPSTREAM_RATE_LIMITING. This mutates
443
+ # w3c_api's process-wide singleton and is not restored afterwards — the
444
+ # crawler is a standalone script, and a shorter inner retry is the right
445
+ # setting for any caller that has the governor in front of it anyway.
446
+ # Best-effort: the setter is w3c_api's only public knob and is not part of
447
+ # a stable contract, so losing it must not fail the run.
448
+ def configure_upstream_rate_limiting
449
+ hal = W3cApi::Hal.instance
450
+ return unless hal.respond_to?(:configure_rate_limiting)
451
+
452
+ # w3c_api >= 0.3.3 cascades this through reset_client -> rebuild_register
453
+ # itself, so the register no longer goes on serving the old policy.
454
+ # Do NOT add a reset_register call after this: rebuild_register
455
+ # re-registers eagerly on purpose (Link#realize raises when the name is
456
+ # absent from lutaml-hal's GlobalRegister), and tearing it back down
457
+ # would break realize for every model fetched before this point.
458
+ hal.configure_rate_limiting(UPSTREAM_RATE_LIMITING)
459
+ rescue StandardError => e
460
+ Util.warn "Could not configure w3c_api rate limiting: #{e.class}: #{e.message}"
461
+ end
462
+
285
463
  def spawn_worker(queue)
286
464
  Thread.new do
287
465
  while (item = queue.pop)
288
- # Once interrupted, drain the queue without processing so the
289
- # producer unblocks and the pool reaches its poison pills quickly.
290
- next if @interrupted
466
+ # Once stopping, drain the queue without processing so the producer
467
+ # unblocks and the pool reaches its poison pills quickly.
468
+ next if stopping?
291
469
 
292
470
  spec, page = item
293
471
  begin
@@ -126,11 +126,11 @@ module Relaton
126
126
  #
127
127
  # Parse docidentifier
128
128
  #
129
- # @return [Array<Bib::Docidentifier>] docidentifier
129
+ # @return [Array<Docidentifier>] docidentifier
130
130
  #
131
131
  def parse_docid
132
132
  id = pub_id(doc_uri)
133
- [Bib::Docidentifier.new(type: "W3C", content: id, primary: true)]
133
+ [Docidentifier.new(type: "W3C", content: id, primary: true)]
134
134
  end
135
135
 
136
136
  #
@@ -371,7 +371,7 @@ module Relaton
371
371
  Bib::Organization.new(
372
372
  name: [Bib::TypedLocalizedString.new(content: "World Wide Web Consortium")],
373
373
  abbreviation: Bib::LocalizedString.new(content: "W3C"),
374
- uri: Bib::Uri.new(content: "https://www.w3.org"),
374
+ uri: [Bib::Uri.new(content: "https://www.w3.org")],
375
375
  )
376
376
  end
377
377
  end
@@ -0,0 +1,48 @@
1
+ module Relaton
2
+ module W3c
3
+ # A W3C document identifier that carries its parsed pubid alongside the
4
+ # source string.
5
+ #
6
+ # `DataParser#pub_id` builds `"W3C REC-xml-names-20091208"` — publisher
7
+ # prefix included — which is exactly the form
8
+ # `Pubid::W3c::Identifier.parse` accepts, so nothing has to synthesize or
9
+ # strip a prefix. `DataFetcher#index_primary` takes `#pubid` straight from
10
+ # here to build the `index-v2` rows.
11
+ #
12
+ # Follows the IHO shape rather than the ISO one: `content=` calls `super`
13
+ # first, so `content` keeps the source string verbatim and serialization is
14
+ # unchanged. ISO re-renders `content` from the pubid, which W3C has no need
15
+ # of and which would alter its published output.
16
+ class Docidentifier < Bib::Docidentifier
17
+ attr_reader :pubid
18
+
19
+ def content=(value)
20
+ super
21
+ @pubid = value && parse(value)
22
+ end
23
+
24
+ # W3C identifiers carry no part, date-as-component or all-parts notion, so
25
+ # the three mutators `Bib::ItemData` broadcasts over every docidentifier
26
+ # are no-ops here rather than the inherited NotImplementedError.
27
+ def remove_part!; end
28
+
29
+ def remove_date!; end
30
+
31
+ def to_all_parts!; end
32
+
33
+ private
34
+
35
+ # An identifier that does not parse is a data defect, so it is reported at
36
+ # ERROR — never at WARN. It does not raise: an already-published record
37
+ # still has to deserialize and render. The crawl escalates the same
38
+ # failure into a tracked GitHub issue (see
39
+ # `DataFetcher#index_primary` and `Core::DataFetcher#report_errors`).
40
+ def parse(value)
41
+ ::Pubid::W3c::Identifier.parse value.to_s
42
+ rescue StandardError => e
43
+ Util.error "Failed to parse pubid `#{value}`: #{e.message}"
44
+ nil
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "lutaml/hal"
4
+ require_relative "../core/governor"
5
+
6
+ module Relaton
7
+ module W3c
8
+ #
9
+ # W3C binding of {Relaton::Core::Governor}.
10
+ #
11
+ # The ladder (60 s → 900 s), the per-round escalation, the jitter, the
12
+ # latched give-up and the Retry-After parsing all live in core, where the
13
+ # ITU-R crawler shares them. What is W3C-specific is exactly two things:
14
+ # which errors mean "rate-limited", and which env vars tune the durations.
15
+ #
16
+ class Governor < Relaton::Core::Governor
17
+ # Errors that mean "you are being rate-limited", as opposed to "this
18
+ # resource is broken". api.w3.org signals throttling with **403 as well as
19
+ # 429** — w3c_api's whole faraday-retry layer is built around 403 being the
20
+ # W3C rate-limit signal — so a 403 must never be recorded as a permanently
21
+ # broken resource either. Requires lutaml-hal >= 0.2.5, which gave 403 its
22
+ # own ForbiddenError class (before that it arrived as a bare Error).
23
+ THROTTLE_ERRORS = [
24
+ ::Lutaml::Hal::TooManyRequestsError,
25
+ ::Lutaml::Hal::ForbiddenError,
26
+ ].freeze
27
+
28
+ # RELATON_W3C_THROTTLE_BASE / _MAX / _GIVEUP.
29
+ ENV_PREFIX = "RELATON_W3C"
30
+ end
31
+ end
32
+ end
@@ -1,5 +1,6 @@
1
1
  require_relative "item_data"
2
2
  require_relative "ext"
3
+ require_relative "docidentifier"
3
4
 
4
5
  module Relaton
5
6
  module W3c
@@ -7,6 +8,8 @@ module Relaton
7
8
  model ItemData
8
9
 
9
10
  attribute :ext, Ext
11
+ attribute :docidentifier, Docidentifier, collection: true,
12
+ initialize_empty: true
10
13
  end
11
14
  end
12
15
  end
@@ -1,5 +1,17 @@
1
1
  module Relaton
2
2
  module W3c
3
+ # The legacy `index-v1` row shape.
4
+ #
5
+ # This is no longer on any lookup or crawl path in this gem: the flavor
6
+ # indexes and searches on `Pubid::W3c::Identifier` (see
7
+ # `Relaton::W3c::Docidentifier` and `INDEXFILE`). It is retained public
8
+ # only so `relaton-data-w3c`'s crawler can keep emitting the bespoke
9
+ # `index-v1` that released relaton v2 consumers read — the same reason
10
+ # `Relaton::Bipm::Id` was retained.
11
+ #
12
+ # Do not add call sites. Its decomposition differs from pubid's: it splits
13
+ # `year` from `date` and keeps a `/`-suffix in `suff`, where pubid folds
14
+ # both into the slug.
3
15
  class PubId
4
16
  PARTS = %i[code stage type year date suff].freeze
5
17
 
@@ -1,4 +1,5 @@
1
1
  require "concurrent/map"
2
+ require_relative "governor"
2
3
 
3
4
  module Relaton
4
5
  module W3c
@@ -7,10 +8,22 @@ module Relaton
7
8
  # resources that failed terminally and returns nil for them — so one broken
8
9
  # link doesn't abort the crawl and isn't re-fetched on every reference.
9
10
  #
10
- # Transient failures are retried upstream: w3c_api retries HTTP 403 (the
11
+ # Transient failures are retried upstream first: w3c_api retries 403 (the
11
12
  # W3C rate-limit signal) and connection/timeout errors, and lutaml-hal
12
- # retries 429 and 5xx. By the time an error surfaces here it is terminal.
13
+ # retries 429 and 5xx. By the time an error surfaces here the resource is
14
+ # either genuinely broken (skipped for the rest of the run) or we are still
15
+ # being rate-limited (handed to the Governor).
16
+ #
17
+ # That distinction is the point: a 429 or 403 means "come back later", not
18
+ # "this document does not exist". Recording one as a permanent skip is what
19
+ # silently cost the Aug-2026 relaton-data-w3c crawl 1,412 documents.
13
20
  module SafeRealize
21
+ # How many times a rate-limited realize is re-attempted, each behind the
22
+ # governor's shared cooldown, before the resource is written off for this
23
+ # run. Three spans the escalating cooldowns without letting one unlucky
24
+ # href monopolise a worker.
25
+ THROTTLE_ATTEMPTS = 3
26
+
14
27
  # Hrefs that failed terminally — one map shared by every includer
15
28
  # (DataFetcher and DataParser) since a broken resource is broken for the
16
29
  # whole crawl. Initialized eagerly (at load, single-threaded) so the
@@ -18,8 +31,26 @@ module Relaton
18
31
  # Concurrent::Map then handles the concurrent reads/writes.
19
32
  @skipped = Concurrent::Map.new
20
33
 
21
- def self.skipped
22
- @skipped
34
+ # Hrefs lost to rate limiting rather than to a broken resource. Kept apart
35
+ # from @skipped because they measure how incomplete the crawl is, not a
36
+ # property of the resource: DataFetcher refuses to save an index once too
37
+ # many pile up.
38
+ @throttled = Concurrent::Map.new
39
+
40
+ # Shared back-pressure for the whole crawl (see Governor).
41
+ @governor = Governor.new
42
+
43
+ class << self
44
+ attr_reader :skipped, :throttled
45
+ attr_accessor :governor
46
+
47
+ # Start a crawl (or a spec example) from a clean slate. The governor is
48
+ # reset in place, so a reference taken before the reset stays live.
49
+ def reset!
50
+ @skipped.clear
51
+ @throttled.clear
52
+ @governor.reset!
53
+ end
23
54
  end
24
55
 
25
56
  # @param parent_resource [Object, nil] the index/page the link came from.
@@ -27,30 +58,88 @@ module Relaton
27
58
  # payload lets the link realize from memory instead of issuing an HTTP
28
59
  # request. nil (the default) preserves the plain remote-fetch behavior.
29
60
  def realize(obj, parent_resource: nil)
61
+ # Once the governor has latched its give-up the run is already lost
62
+ # (DataFetcher#guard_rate_limited raises and nothing is saved) and the
63
+ # host has just banned us, so every further request only extends the
64
+ # ban into the next run. #wait alone stops WAITING, not asking: the
65
+ # 2026-08-31 crawl spent its last 11 minutes firing at a banned host
66
+ # because the per-spec fan-out had no back-pressure of its own.
67
+ #
68
+ # This is the one choke point every request in the crawl passes
69
+ # through, so one check covers DataFetcher#fetch_versions and all
70
+ # seven of DataParser's realize sites. They already tolerate nil —
71
+ # that is the SafeRealize contract.
72
+ return nil if SafeRealize.governor.exhausted?
73
+
30
74
  href = resolve_href(obj)
31
75
  return nil if SafeRealize.skipped.key?(href)
32
76
 
33
- obj.realize(parent_resource: parent_resource)
34
- rescue Lutaml::Hal::ConnectionError, Lutaml::Hal::TimeoutError, Faraday::Error, Net::OpenTimeout => e
35
- # Network-level failure (already retried by w3c_api). The resource itself
36
- # is fine, so don't skip it permanently — a later reference can try again.
37
- Util.warn "Failed to realize object: #{href}, error: #{e.message}"
38
- nil
39
- rescue Lutaml::Hal::NotFoundError
40
- Util.warn "Object not found: #{href}"
41
- SafeRealize.skipped[href] = true
42
- nil
43
- rescue Lutaml::Hal::Error => e
44
- # Definitive upstream error (403 rate-limit, 5xx, 429) already retried by
45
- # w3c_api / lutaml-hal. Skip the broken/unavailable resource rather than
46
- # re-hitting it for every link that references it.
47
- Util.warn "Skipping #{href}, upstream error after retries: #{e.message}"
48
- SafeRealize.skipped[href] = true
49
- nil
77
+ attempt = 0
78
+ begin
79
+ attempt += 1
80
+ SafeRealize.governor.wait
81
+ realized = obj.realize(parent_resource: parent_resource)
82
+ # Only a realize that actually went to the network says anything about
83
+ # the limiter. With a parent_resource, lutaml-hal serves the object
84
+ # straight out of the page's `_embedded` payload and never issues a
85
+ # request (link.rb: "Priority 1: check embedded content first") — and
86
+ # that is the common case, one per specification. Reporting those as
87
+ # successes would clear the cooldown and reset the escalation ladder
88
+ # between every pair of version-history 429s, so the governor could
89
+ # never reach its give-up threshold: exactly the 60s re-trip loop this
90
+ # class exists to break.
91
+ SafeRealize.governor.succeeded! unless parent_resource
92
+ # The document made it into the dataset after all, so it must stop
93
+ # counting against the crawl's throttle budget.
94
+ SafeRealize.throttled.delete(href)
95
+ realized
96
+ rescue *Governor::THROTTLE_ERRORS => e
97
+ retry if record_throttle(href, e, attempt)
98
+ nil
99
+ rescue Lutaml::Hal::ConnectionError, Lutaml::Hal::TimeoutError, Faraday::Error, Net::OpenTimeout => e
100
+ # Network-level failure (already retried by w3c_api). The resource itself
101
+ # is fine, so don't skip it permanently — a later reference can try again.
102
+ Util.warn "Failed to realize object: #{href}, error: #{e.message}"
103
+ nil
104
+ rescue Lutaml::Hal::NotFoundError
105
+ Util.warn "Object not found: #{href}"
106
+ SafeRealize.skipped[href] = true
107
+ nil
108
+ rescue Lutaml::Hal::Error => e
109
+ # Definitive upstream error (5xx, 400/401, …) already retried by
110
+ # w3c_api / lutaml-hal. Skip the broken/unavailable resource rather than
111
+ # re-hitting it for every link that references it. 5xx stays here
112
+ # deliberately: the handful of persistent per-resource 500s a healthy
113
+ # crawl sees are broken records, not throttling, and routing them
114
+ # through the governor would open a pool-wide cooldown for each one.
115
+ Util.warn "Skipping #{href}, upstream error after retries: #{e.message}"
116
+ SafeRealize.skipped[href] = true
117
+ nil
118
+ end
50
119
  end
51
120
 
52
121
  private
53
122
 
123
+ #
124
+ # Record a rate-limited attempt and decide whether to try again.
125
+ #
126
+ # @return [Boolean] true to retry — the caller re-enters #realize's begin
127
+ # block, whose #wait sits out the shared cooldown first
128
+ #
129
+ def record_throttle(href, error, attempt)
130
+ gave_up = SafeRealize.governor.throttled!(
131
+ retry_after: Governor.retry_after(error),
132
+ )
133
+ # Once the governor has declared the crawl rate-limited there is nothing
134
+ # to gain from more attempts — the run is about to abort anyway.
135
+ return true if !gave_up && attempt < THROTTLE_ATTEMPTS
136
+
137
+ Util.warn "Throttled out: #{href}, still rate-limited after " \
138
+ "#{attempt} attempt(s): #{error.message}"
139
+ SafeRealize.throttled[href] = true
140
+ false
141
+ end
142
+
54
143
  def resolve_href(obj)
55
144
  obj.href || obj.links.self.href
56
145
  end
data/lib/relaton/w3c.rb CHANGED
@@ -1,3 +1,5 @@
1
+ require "pubid"
2
+ require "pubid/w3c"
1
3
  require "relaton/bib"
2
4
  require "relaton/index"
3
5
  require_relative "version"
@@ -6,10 +8,19 @@ require_relative "w3c/item"
6
8
  require_relative "w3c/bibitem"
7
9
  require_relative "w3c/bibdata"
8
10
  require_relative "w3c/bibliography"
11
+ # Not used by this gem any more; loaded so relaton-data-w3c's crawler can
12
+ # require "relaton/w3c" and still reach the legacy index-v1 row shape.
13
+ require_relative "w3c/pubid"
9
14
 
10
15
  module Relaton
11
16
  module W3c
12
- INDEXFILE = "index-v1".freeze
17
+ # The runtime index is the pubid-backed `index-v2`, whose rows are
18
+ # `Pubid::W3c::Identifier` leaves serialized as `_type: pubid:w3c:*`, built
19
+ # and read via `pubid_class: ::Pubid::W3c::Identifier`. The bespoke
20
+ # `index-v1` is relaton-v2 legacy and is no longer produced here —
21
+ # `relaton-data-w3c`'s crawler emits it from the retained public
22
+ # `Relaton::W3c::PubId`.
23
+ INDEXFILE = "index-v2".freeze
13
24
 
14
25
  class Error < StandardError; end
15
26