sec_api 1.0.0

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 (75) hide show
  1. checksums.yaml +7 -0
  2. data/.devcontainer/Dockerfile +54 -0
  3. data/.devcontainer/README.md +178 -0
  4. data/.devcontainer/devcontainer.json +46 -0
  5. data/.devcontainer/docker-compose.yml +28 -0
  6. data/.devcontainer/post-create.sh +51 -0
  7. data/.devcontainer/post-start.sh +44 -0
  8. data/.rspec +3 -0
  9. data/.standard.yml +3 -0
  10. data/CHANGELOG.md +5 -0
  11. data/CLAUDE.md +0 -0
  12. data/LICENSE.txt +21 -0
  13. data/MIGRATION.md +274 -0
  14. data/README.md +370 -0
  15. data/Rakefile +10 -0
  16. data/config/secapi.yml.example +57 -0
  17. data/docs/development-guide.md +291 -0
  18. data/docs/enumerator_pattern_design.md +483 -0
  19. data/docs/examples/README.md +58 -0
  20. data/docs/examples/backfill_filings.rb +419 -0
  21. data/docs/examples/instrumentation.rb +583 -0
  22. data/docs/examples/query_builder.rb +308 -0
  23. data/docs/examples/streaming_notifications.rb +491 -0
  24. data/docs/index.md +244 -0
  25. data/docs/migration-guide-v1.md +1091 -0
  26. data/docs/pre-review-checklist.md +145 -0
  27. data/docs/project-overview.md +90 -0
  28. data/docs/project-scan-report.json +60 -0
  29. data/docs/source-tree-analysis.md +190 -0
  30. data/lib/sec_api/callback_helper.rb +49 -0
  31. data/lib/sec_api/client.rb +606 -0
  32. data/lib/sec_api/collections/filings.rb +267 -0
  33. data/lib/sec_api/collections/fulltext_results.rb +86 -0
  34. data/lib/sec_api/config.rb +590 -0
  35. data/lib/sec_api/deep_freezable.rb +42 -0
  36. data/lib/sec_api/errors/authentication_error.rb +24 -0
  37. data/lib/sec_api/errors/configuration_error.rb +5 -0
  38. data/lib/sec_api/errors/error.rb +75 -0
  39. data/lib/sec_api/errors/network_error.rb +26 -0
  40. data/lib/sec_api/errors/not_found_error.rb +23 -0
  41. data/lib/sec_api/errors/pagination_error.rb +28 -0
  42. data/lib/sec_api/errors/permanent_error.rb +29 -0
  43. data/lib/sec_api/errors/rate_limit_error.rb +57 -0
  44. data/lib/sec_api/errors/reconnection_error.rb +34 -0
  45. data/lib/sec_api/errors/server_error.rb +25 -0
  46. data/lib/sec_api/errors/transient_error.rb +28 -0
  47. data/lib/sec_api/errors/validation_error.rb +23 -0
  48. data/lib/sec_api/extractor.rb +122 -0
  49. data/lib/sec_api/filing_journey.rb +477 -0
  50. data/lib/sec_api/mapping.rb +125 -0
  51. data/lib/sec_api/metrics_collector.rb +411 -0
  52. data/lib/sec_api/middleware/error_handler.rb +250 -0
  53. data/lib/sec_api/middleware/instrumentation.rb +186 -0
  54. data/lib/sec_api/middleware/rate_limiter.rb +541 -0
  55. data/lib/sec_api/objects/data_file.rb +34 -0
  56. data/lib/sec_api/objects/document_format_file.rb +45 -0
  57. data/lib/sec_api/objects/entity.rb +92 -0
  58. data/lib/sec_api/objects/extracted_data.rb +118 -0
  59. data/lib/sec_api/objects/fact.rb +147 -0
  60. data/lib/sec_api/objects/filing.rb +197 -0
  61. data/lib/sec_api/objects/fulltext_result.rb +66 -0
  62. data/lib/sec_api/objects/period.rb +96 -0
  63. data/lib/sec_api/objects/stream_filing.rb +194 -0
  64. data/lib/sec_api/objects/xbrl_data.rb +356 -0
  65. data/lib/sec_api/query.rb +423 -0
  66. data/lib/sec_api/rate_limit_state.rb +130 -0
  67. data/lib/sec_api/rate_limit_tracker.rb +154 -0
  68. data/lib/sec_api/stream.rb +841 -0
  69. data/lib/sec_api/structured_logger.rb +199 -0
  70. data/lib/sec_api/types.rb +32 -0
  71. data/lib/sec_api/version.rb +42 -0
  72. data/lib/sec_api/xbrl.rb +220 -0
  73. data/lib/sec_api.rb +137 -0
  74. data/sig/sec_api.rbs +4 -0
  75. metadata +217 -0
@@ -0,0 +1,590 @@
1
+ require "anyway_config"
2
+
3
+ module SecApi
4
+ # Configuration for the SecApi client.
5
+ #
6
+ # Configuration Layering (via anyway_config):
7
+ # Sources are applied in order of increasing precedence:
8
+ # 1. Defaults (defined in initialize method)
9
+ # 2. YAML file (config/secapi.yml)
10
+ # 3. Environment variables (SECAPI_API_KEY, SECAPI_BASE_URL, etc.)
11
+ # Later sources override earlier ones - env vars always win.
12
+ # This allows production deployments to use env vars while keeping
13
+ # YAML for development defaults.
14
+ #
15
+ # @example Basic configuration
16
+ # config = SecApi::Config.new(api_key: "your_api_key")
17
+ #
18
+ # @example With custom rate limit settings
19
+ # config = SecApi::Config.new(
20
+ # api_key: "your_api_key",
21
+ # rate_limit_threshold: 0.2, # Throttle at 20% remaining
22
+ # on_throttle: ->(info) { Rails.logger.warn("Throttling: #{info}") }
23
+ # )
24
+ #
25
+ # @!attribute [rw] rate_limit_threshold
26
+ # @return [Float] Threshold for proactive throttling (0.0-1.0). When the
27
+ # percentage of remaining requests drops below this value, the middleware
28
+ # will sleep until the rate limit window resets. Default is 0.1 (10%).
29
+ # Set to 0.0 to disable proactive throttling, or 1.0 to always throttle.
30
+ #
31
+ # @!attribute [rw] on_throttle
32
+ # @return [Proc, nil] Optional callback invoked when proactive throttling occurs.
33
+ # Receives a hash with the following keys:
34
+ # - :remaining [Integer] - Requests remaining in current window
35
+ # - :limit [Integer] - Total requests allowed per window
36
+ # - :delay [Float] - Seconds the request will be delayed
37
+ # - :reset_at [Time] - When the rate limit window resets
38
+ # - :request_id [String] - UUID for tracing this request across callbacks
39
+ #
40
+ # @example New Relic integration
41
+ # config = SecApi::Config.new(
42
+ # api_key: "...",
43
+ # on_throttle: ->(info) {
44
+ # NewRelic::Agent.record_custom_event(
45
+ # "SecApiRateLimitThrottle",
46
+ # remaining: info[:remaining],
47
+ # delay: info[:delay],
48
+ # request_id: info[:request_id]
49
+ # )
50
+ # }
51
+ # )
52
+ #
53
+ # @example Datadog StatsD integration
54
+ # require 'datadog/statsd'
55
+ # statsd = Datadog::Statsd.new('localhost', 8125)
56
+ #
57
+ # config = SecApi::Config.new(
58
+ # api_key: "...",
59
+ # on_throttle: ->(info) {
60
+ # statsd.increment('sec_api.rate_limit.throttle')
61
+ # statsd.gauge('sec_api.rate_limit.remaining', info[:remaining])
62
+ # statsd.histogram('sec_api.rate_limit.delay', info[:delay])
63
+ # }
64
+ # )
65
+ #
66
+ # @example StatsD integration
67
+ # config = SecApi::Config.new(
68
+ # api_key: "...",
69
+ # on_throttle: ->(info) {
70
+ # StatsD.increment("sec_api.throttle")
71
+ # StatsD.gauge("sec_api.remaining", info[:remaining])
72
+ # }
73
+ # )
74
+ #
75
+ # @!attribute [rw] on_rate_limit
76
+ # @return [Proc, nil] Optional callback invoked when a 429 rate limit response
77
+ # is received and will be retried. This is the reactive callback (after hitting
78
+ # the limit), distinct from on_throttle which is proactive (before hitting limit).
79
+ # Receives a hash with the following keys:
80
+ # - :retry_after [Integer, nil] - Seconds to wait (from Retry-After header)
81
+ # - :reset_at [Time, nil] - When the rate limit resets (from X-RateLimit-Reset)
82
+ # - :attempt [Integer] - Current retry attempt number
83
+ # - :request_id [String, nil] - UUID for tracing this request across callbacks
84
+ #
85
+ # @example New Relic integration for 429 responses
86
+ # config = SecApi::Config.new(
87
+ # api_key: "...",
88
+ # on_rate_limit: ->(info) {
89
+ # NewRelic::Agent.record_custom_event(
90
+ # "SecApiRateLimit429",
91
+ # retry_after: info[:retry_after],
92
+ # attempt: info[:attempt],
93
+ # request_id: info[:request_id]
94
+ # )
95
+ # }
96
+ # )
97
+ #
98
+ # @example Alert threshold recommendation
99
+ # # Consider alerting when on_rate_limit is invoked frequently:
100
+ # # - Warning: >5 rate limit hits per minute
101
+ # # - Critical: >20 rate limit hits per minute
102
+ #
103
+ # @!attribute [rw] queue_wait_warning_threshold
104
+ # @return [Integer] Threshold in seconds for excessive wait warnings.
105
+ # When a request is queued and the wait time exceeds this threshold,
106
+ # the on_excessive_wait callback is invoked. Default is 300 (5 minutes).
107
+ #
108
+ # @!attribute [rw] on_queue
109
+ # @return [Proc, nil] Optional callback invoked when a request is queued
110
+ # due to exhausted rate limit (remaining = 0). Receives a hash with:
111
+ # - :queue_size [Integer] - Number of requests currently queued
112
+ # - :wait_time [Float] - Estimated seconds until rate limit resets
113
+ # - :reset_at [Time] - When the rate limit window resets
114
+ # - :request_id [String] - UUID for tracing this request across callbacks
115
+ #
116
+ # @example Datadog queue depth monitoring
117
+ # statsd = Datadog::Statsd.new('localhost', 8125)
118
+ #
119
+ # config = SecApi::Config.new(
120
+ # api_key: "...",
121
+ # on_queue: ->(info) {
122
+ # statsd.gauge('sec_api.rate_limit.queue_size', info[:queue_size])
123
+ # statsd.histogram('sec_api.rate_limit.wait_time', info[:wait_time])
124
+ # }
125
+ # )
126
+ #
127
+ # @!attribute [rw] on_excessive_wait
128
+ # @return [Proc, nil] Optional callback invoked when queue wait time exceeds
129
+ # queue_wait_warning_threshold. The request continues waiting after callback.
130
+ # Receives a hash with:
131
+ # - :wait_time [Float] - Seconds the request will wait
132
+ # - :threshold [Integer] - The configured warning threshold
133
+ # - :reset_at [Time] - When the rate limit resets
134
+ # - :request_id [String] - UUID for tracing this request across callbacks
135
+ #
136
+ # @!attribute [rw] on_dequeue
137
+ # @return [Proc, nil] Optional callback invoked when a request exits the queue
138
+ # after waiting for rate limit reset. Receives a hash with:
139
+ # - :queue_size [Integer] - Number of requests remaining in queue
140
+ # - :waited [Float] - Actual seconds the request waited
141
+ # - :request_id [String] - UUID for tracing this request across callbacks
142
+ #
143
+ # @!attribute [rw] logger
144
+ # @return [Logger, nil] Optional logger instance for structured rate limit logging.
145
+ # When configured, the middleware will log rate limit events (throttle, queue, 429)
146
+ # as JSON for compatibility with monitoring tools like ELK, Splunk, and Datadog.
147
+ # Compatible with Ruby Logger and ActiveSupport::Logger interfaces.
148
+ # Set to nil (default) to disable logging.
149
+ #
150
+ # @example Using Rails logger
151
+ # config = SecApi::Config.new(
152
+ # api_key: "...",
153
+ # logger: Rails.logger,
154
+ # log_level: :info
155
+ # )
156
+ #
157
+ # @example Log output format (JSON)
158
+ # # {"event":"secapi.rate_limit.throttle","request_id":"abc-123","remaining":5,"delay":30.2}
159
+ # # {"event":"secapi.rate_limit.queue","request_id":"abc-123","queue_size":3,"wait_time":60}
160
+ # # {"event":"secapi.rate_limit.exceeded","request_id":"abc-123","retry_after":30,"attempt":1}
161
+ #
162
+ # @!attribute [rw] log_level
163
+ # @return [Symbol] Log level for rate limit events. Default is :info.
164
+ # Valid values: :debug, :info, :warn, :error
165
+ #
166
+ # @!attribute [rw] on_callback_error
167
+ # @return [Proc, nil] Optional callback invoked when a stream callback raises
168
+ # an exception. The stream continues processing after this callback returns.
169
+ # Receives a hash with the following keys:
170
+ # - :error [Exception] - The exception that was raised
171
+ # - :filing [SecApi::Objects::StreamFiling] - The filing being processed
172
+ # - :accession_no [String] - SEC accession number
173
+ # - :ticker [String, nil] - Stock ticker symbol
174
+ #
175
+ # @example Log to external error service
176
+ # config = SecApi::Config.new(
177
+ # api_key: "...",
178
+ # on_callback_error: ->(info) {
179
+ # Bugsnag.notify(info[:error], {
180
+ # filing: info[:accession_no],
181
+ # ticker: info[:ticker]
182
+ # })
183
+ # }
184
+ # )
185
+ #
186
+ # @example Custom error handling
187
+ # config = SecApi::Config.new(
188
+ # api_key: "...",
189
+ # on_callback_error: ->(info) {
190
+ # Rails.logger.error("Stream callback failed: #{info[:error].message}")
191
+ # ErrorQueue.push(info[:error], info[:filing].to_h)
192
+ # }
193
+ # )
194
+ #
195
+ # @!attribute [rw] on_reconnect
196
+ # @return [Proc, nil] Optional callback invoked when WebSocket reconnection succeeds.
197
+ # Receives a hash with the following keys:
198
+ # - :attempt_count [Integer] - Number of reconnection attempts before success
199
+ # - :downtime_seconds [Float] - Total time disconnected in seconds
200
+ #
201
+ # @example Track reconnections in metrics
202
+ # config = SecApi::Config.new(
203
+ # api_key: "...",
204
+ # on_reconnect: ->(info) {
205
+ # StatsD.increment("sec_api.stream.reconnected")
206
+ # StatsD.gauge("sec_api.stream.downtime", info[:downtime_seconds])
207
+ # }
208
+ # )
209
+ #
210
+ # @!attribute [rw] stream_max_reconnect_attempts
211
+ # @return [Integer] Maximum number of WebSocket reconnection attempts before
212
+ # giving up and raising ReconnectionError. Default is 10.
213
+ # Set to 0 to disable auto-reconnect entirely.
214
+ #
215
+ # @!attribute [rw] stream_initial_reconnect_delay
216
+ # @return [Float] Initial delay in seconds before the first reconnection attempt.
217
+ # Subsequent attempts use exponential backoff. Default is 1.0 second.
218
+ #
219
+ # @!attribute [rw] stream_max_reconnect_delay
220
+ # @return [Float] Maximum delay in seconds between reconnection attempts.
221
+ # Caps the exponential backoff to prevent excessively long waits. Default is 60.0 seconds.
222
+ #
223
+ # @!attribute [rw] stream_backoff_multiplier
224
+ # @return [Integer, Float] Multiplier for exponential backoff between reconnection
225
+ # attempts. Delay formula: min(initial * (multiplier ^ attempt), max_delay).
226
+ # Default is 2 (delays: 1s, 2s, 4s, 8s, ..., capped at max).
227
+ #
228
+ # @!attribute [rw] on_filing
229
+ # @return [Proc, nil] Optional callback invoked when a filing is received via stream.
230
+ # Called for ALL filings before filtering and before the user callback. Use for
231
+ # instrumentation and latency monitoring of the full filing stream.
232
+ # @example Track filing latency with StatsD
233
+ # on_filing: ->(filing:, latency_ms:, received_at:) {
234
+ # StatsD.histogram("sec_api.stream.latency_ms", latency_ms)
235
+ # StatsD.increment("sec_api.stream.filings_received")
236
+ # }
237
+ # @example Log latency with structured logging
238
+ # on_filing: ->(filing:, latency_ms:, received_at:) {
239
+ # Rails.logger.info("Filing received", {
240
+ # ticker: filing.ticker,
241
+ # form_type: filing.form_type,
242
+ # latency_ms: latency_ms
243
+ # })
244
+ # }
245
+ #
246
+ # @!attribute [rw] stream_latency_warning_threshold
247
+ # @return [Float] Latency threshold in seconds before logging a warning (default: 120).
248
+ # When a filing's delivery latency exceeds this threshold, a warning is logged.
249
+ # Set to 120 seconds (2 minutes) to align with NFR1 requirements.
250
+ #
251
+ # @!attribute [rw] on_request
252
+ # @return [Proc, nil] Optional callback invoked BEFORE each REST API request is sent.
253
+ # Use for request logging, tracing, and custom instrumentation.
254
+ # Receives a hash with the following keyword arguments:
255
+ # - :request_id [String] - UUID for correlating this request across all callbacks
256
+ # - :method [Symbol] - HTTP method (:get, :post, etc.)
257
+ # - :url [String] - Full request URL
258
+ # - :headers [Hash] - Request headers (Authorization header is sanitized/excluded)
259
+ #
260
+ # @example Request logging integration
261
+ # config = SecApi::Config.new(
262
+ # api_key: "...",
263
+ # on_request: ->(request_id:, method:, url:, headers:) {
264
+ # Rails.logger.info("SEC API Request", {
265
+ # request_id: request_id,
266
+ # method: method,
267
+ # url: url
268
+ # })
269
+ # }
270
+ # )
271
+ #
272
+ # @example OpenTelemetry tracing integration
273
+ # config = SecApi::Config.new(
274
+ # api_key: "...",
275
+ # on_request: ->(request_id:, method:, url:, headers:) {
276
+ # span = OpenTelemetry::Trace.current_span
277
+ # span.set_attribute("sec_api.request_id", request_id)
278
+ # span.set_attribute("http.method", method.to_s.upcase)
279
+ # span.set_attribute("http.url", url)
280
+ # }
281
+ # )
282
+ #
283
+ # @!attribute [rw] on_response
284
+ # @return [Proc, nil] Optional callback invoked AFTER each REST API response is received.
285
+ # Use for response metrics, latency tracking, and observability dashboards.
286
+ # Receives a hash with the following keyword arguments:
287
+ # - :request_id [String] - UUID for correlating with the corresponding on_request callback
288
+ # - :status [Integer] - HTTP status code (200, 429, 500, etc.)
289
+ # - :duration_ms [Integer] - Request duration in milliseconds
290
+ # - :url [String] - Request URL
291
+ # - :method [Symbol] - HTTP method
292
+ #
293
+ # @example StatsD/Datadog metrics integration
294
+ # config = SecApi::Config.new(
295
+ # api_key: "...",
296
+ # on_response: ->(request_id:, status:, duration_ms:, url:, method:) {
297
+ # StatsD.histogram("sec_api.request.duration_ms", duration_ms)
298
+ # StatsD.increment("sec_api.request.#{status >= 400 ? 'error' : 'success'}")
299
+ # }
300
+ # )
301
+ #
302
+ # @example Prometheus metrics integration
303
+ # config = SecApi::Config.new(
304
+ # api_key: "...",
305
+ # on_response: ->(request_id:, status:, duration_ms:, url:, method:) {
306
+ # SEC_API_REQUEST_DURATION.observe(duration_ms / 1000.0, labels: {method: method, status: status})
307
+ # SEC_API_REQUESTS_TOTAL.increment(labels: {method: method, status: status})
308
+ # }
309
+ # )
310
+ #
311
+ # @!attribute [rw] on_retry
312
+ # @return [Proc, nil] Optional callback invoked BEFORE each retry attempt for transient errors.
313
+ # Use for retry monitoring and alerting on degraded API connectivity.
314
+ # Receives a hash with the following keyword arguments:
315
+ # - :request_id [String] - UUID for correlating with request/response callbacks
316
+ # - :attempt [Integer] - Current retry attempt number (1-indexed)
317
+ # - :max_attempts [Integer] - Maximum retry attempts configured
318
+ # - :error_class [String] - Name of the exception class that triggered retry
319
+ # - :error_message [String] - Exception message
320
+ # - :will_retry_in [Float] - Seconds until retry (from exponential backoff)
321
+ #
322
+ # @note This callback is distinct from on_error. on_retry fires BEFORE each retry
323
+ # attempt, while on_error fires on FINAL failure (all retries exhausted).
324
+ #
325
+ # @example Retry monitoring with StatsD
326
+ # config = SecApi::Config.new(
327
+ # api_key: "...",
328
+ # on_retry: ->(request_id:, attempt:, max_attempts:, error_class:, error_message:, will_retry_in:) {
329
+ # StatsD.increment("sec_api.retry", tags: ["attempt:#{attempt}", "error:#{error_class}"])
330
+ # logger.warn("SEC API retry", request_id: request_id, attempt: attempt, error: error_class)
331
+ # }
332
+ # )
333
+ #
334
+ # @example Alert on repeated retries
335
+ # config = SecApi::Config.new(
336
+ # api_key: "...",
337
+ # on_retry: ->(request_id:, attempt:, max_attempts:, error_class:, error_message:, will_retry_in:) {
338
+ # if attempt >= 3
339
+ # AlertService.warn("SEC API degraded", {
340
+ # request_id: request_id,
341
+ # attempt: attempt,
342
+ # max_attempts: max_attempts,
343
+ # error: error_class
344
+ # })
345
+ # end
346
+ # }
347
+ # )
348
+ #
349
+ # @!attribute [rw] on_error
350
+ # @return [Proc, nil] Optional callback invoked when a REST API request ultimately fails
351
+ # (after all retry attempts are exhausted). Use for error tracking and alerting.
352
+ # Receives a hash with the following keyword arguments:
353
+ # - :request_id [String] - UUID for correlating with request/response callbacks
354
+ # - :error [Exception] - The exception that caused the failure
355
+ # - :url [String] - Request URL
356
+ # - :method [Symbol] - HTTP method
357
+ #
358
+ # @note This callback is distinct from on_retry. on_error fires on FINAL failure
359
+ # (all retries exhausted), while on_retry fires BEFORE each retry attempt.
360
+ #
361
+ # @example Bugsnag/Sentry error tracking
362
+ # config = SecApi::Config.new(
363
+ # api_key: "...",
364
+ # on_error: ->(request_id:, error:, url:, method:) {
365
+ # Bugsnag.notify(error, {
366
+ # request_id: request_id,
367
+ # url: url,
368
+ # method: method
369
+ # })
370
+ # }
371
+ # )
372
+ #
373
+ # @example Custom alerting integration
374
+ # config = SecApi::Config.new(
375
+ # api_key: "...",
376
+ # on_error: ->(request_id:, error:, url:, method:) {
377
+ # AlertService.send_alert(
378
+ # severity: error.is_a?(SecApi::PermanentError) ? :high : :medium,
379
+ # message: "SEC API request failed: #{error.message}",
380
+ # context: {request_id: request_id, url: url}
381
+ # )
382
+ # }
383
+ # )
384
+ #
385
+ # @!attribute [rw] default_logging
386
+ # @return [Boolean] When true and logger is configured, automatically sets up
387
+ # structured logging callbacks for all request lifecycle events using
388
+ # {SecApi::StructuredLogger}. Default: false.
389
+ # Explicit callback configurations take precedence over default logging.
390
+ #
391
+ # @example Enable default structured logging
392
+ # config = SecApi::Config.new(
393
+ # api_key: "...",
394
+ # logger: Rails.logger,
395
+ # default_logging: true
396
+ # )
397
+ # # Logs: secapi.request.start, secapi.request.complete, secapi.request.retry, secapi.request.error
398
+ #
399
+ # @example Override specific callbacks while using default logging
400
+ # config = SecApi::Config.new(
401
+ # api_key: "...",
402
+ # logger: Rails.logger,
403
+ # default_logging: true,
404
+ # on_error: ->(request_id:, error:, url:, method:) {
405
+ # # Custom error handling takes precedence over default logging
406
+ # Bugsnag.notify(error)
407
+ # }
408
+ # )
409
+ #
410
+ # @!attribute [rw] metrics_backend
411
+ # @return [Object, nil] Metrics backend instance (StatsD, Datadog::Statsd, etc.).
412
+ # When configured, automatically sets up metrics callbacks for all operations
413
+ # using {SecApi::MetricsCollector}. The backend must respond to `increment`,
414
+ # `histogram`, and/or `gauge` methods. Default: nil.
415
+ # Explicit callback configurations take precedence over default metrics.
416
+ #
417
+ # @example StatsD backend
418
+ # require 'statsd-ruby'
419
+ # config = SecApi::Config.new(
420
+ # api_key: "...",
421
+ # metrics_backend: StatsD.new('localhost', 8125)
422
+ # )
423
+ # # Metrics automatically collected: sec_api.requests.*, sec_api.retries.*, etc.
424
+ #
425
+ # @example Datadog StatsD backend
426
+ # require 'datadog/statsd'
427
+ # config = SecApi::Config.new(
428
+ # api_key: "...",
429
+ # metrics_backend: Datadog::Statsd.new('localhost', 8125)
430
+ # )
431
+ # # Metrics include tags: method, status, error_class, attempt
432
+ #
433
+ # @example With custom callbacks (metrics_backend + custom on_error)
434
+ # config = SecApi::Config.new(
435
+ # api_key: "...",
436
+ # metrics_backend: statsd,
437
+ # on_error: ->(request_id:, error:, url:, method:) {
438
+ # # Custom error handling takes precedence over default metrics
439
+ # Bugsnag.notify(error)
440
+ # # You can still call MetricsCollector manually if needed
441
+ # SecApi::MetricsCollector.record_error(statsd, error_class: error.class.name, method: method)
442
+ # }
443
+ # )
444
+ #
445
+ # @example Combined with default_logging
446
+ # config = SecApi::Config.new(
447
+ # api_key: "...",
448
+ # logger: Rails.logger,
449
+ # default_logging: true,
450
+ # metrics_backend: Datadog::Statsd.new('localhost', 8125)
451
+ # )
452
+ # # Both logging AND metrics are automatically configured
453
+ #
454
+ class Config < Anyway::Config
455
+ config_name :secapi
456
+
457
+ attr_config :api_key,
458
+ :base_url,
459
+ :retry_max_attempts,
460
+ :retry_initial_delay,
461
+ :retry_max_delay,
462
+ :retry_backoff_factor,
463
+ :request_timeout,
464
+ :rate_limit_threshold,
465
+ :queue_wait_warning_threshold,
466
+ :on_request,
467
+ :on_response,
468
+ :on_retry,
469
+ :on_error,
470
+ :on_throttle,
471
+ :on_rate_limit,
472
+ :on_queue,
473
+ :on_dequeue,
474
+ :on_excessive_wait,
475
+ :on_callback_error,
476
+ :on_reconnect,
477
+ :on_filing,
478
+ :logger,
479
+ :log_level,
480
+ :stream_max_reconnect_attempts,
481
+ :stream_initial_reconnect_delay,
482
+ :stream_max_reconnect_delay,
483
+ :stream_backoff_multiplier,
484
+ :stream_latency_warning_threshold,
485
+ :default_logging,
486
+ :metrics_backend
487
+
488
+ # Default values with rationale for each setting.
489
+ # These defaults are chosen to balance reliability with responsiveness.
490
+ def initialize(*)
491
+ super
492
+ self.base_url ||= "https://api.sec-api.io"
493
+
494
+ # Retry defaults (NFR5: 95%+ automatic recovery from transient failures)
495
+ # 5 attempts: Empirically provides >95% recovery for typical transient issues.
496
+ # Formula: P(all_fail) = 0.1^5 = 0.00001 (assuming 10% failure rate per attempt)
497
+ self.retry_max_attempts ||= 5
498
+ # 1 second initial delay: Fast enough to feel responsive, slow enough to allow
499
+ # transient issues (network blips, brief overloads) to resolve.
500
+ self.retry_initial_delay ||= 1.0
501
+ # 60 second max delay: Acceptable for backfill/batch operations, prevents
502
+ # excessive delays for interactive use cases.
503
+ self.retry_max_delay ||= 60.0
504
+ # Factor 2: Standard exponential backoff (1s, 2s, 4s, 8s, 16s, 32s, 60s).
505
+ # Doubles each attempt, providing geometric spacing per industry convention.
506
+ self.retry_backoff_factor ||= 2
507
+ self.request_timeout ||= 30
508
+
509
+ # Rate limiting defaults (FR5: proactive throttling)
510
+ # 10% threshold: Safety buffer to avoid 429 responses. At 100 req/min limit,
511
+ # this gives ~10 requests buffer. Lower risks 429s; higher wastes capacity.
512
+ self.rate_limit_threshold ||= 0.1
513
+ self.queue_wait_warning_threshold ||= 300 # 5 minutes
514
+ self.log_level ||= :info
515
+
516
+ # Stream reconnection defaults (Story 6.4)
517
+ self.stream_max_reconnect_attempts ||= 10
518
+ self.stream_initial_reconnect_delay ||= 1.0
519
+ self.stream_max_reconnect_delay ||= 60.0
520
+ self.stream_backoff_multiplier ||= 2
521
+ # Stream latency defaults (Story 6.5 / NFR1: <2 minute delivery)
522
+ self.stream_latency_warning_threshold ||= 120.0
523
+ # Structured logging defaults (Story 7.3)
524
+ self.default_logging = false if default_logging.nil?
525
+ end
526
+
527
+ # Validates configuration and raises ConfigurationError for invalid values.
528
+ # Called automatically during Client initialization.
529
+ #
530
+ # Validation philosophy: Fail fast with actionable error messages.
531
+ # Invalid config should never reach the API - catch it at startup.
532
+ #
533
+ # @raise [ConfigurationError] if any configuration value is invalid
534
+ # @return [void]
535
+ def validate!
536
+ # API key validation: Reject nil, empty, and obviously invalid keys.
537
+ # Why check length < 10? Real sec-api.io keys are ~40 chars. Short strings
538
+ # are likely test values or typos that would cause confusing 401 errors.
539
+ if api_key.nil? || api_key.empty?
540
+ raise ConfigurationError, missing_api_key_message
541
+ end
542
+
543
+ # Reject placeholder values that users copy from documentation.
544
+ # Better to fail here with clear message than get cryptic 401 from API.
545
+ if api_key.include?("your_api_key_here") || api_key.length < 10
546
+ raise ConfigurationError, invalid_api_key_message
547
+ end
548
+
549
+ # Retry configuration validation
550
+ if retry_max_attempts <= 0
551
+ raise ConfigurationError, "retry_max_attempts must be positive"
552
+ end
553
+
554
+ if retry_initial_delay <= 0
555
+ raise ConfigurationError, "retry_initial_delay must be positive"
556
+ end
557
+
558
+ if retry_max_delay <= 0
559
+ raise ConfigurationError, "retry_max_delay must be positive"
560
+ end
561
+
562
+ if retry_max_delay < retry_initial_delay
563
+ raise ConfigurationError, "retry_max_delay must be >= retry_initial_delay"
564
+ end
565
+
566
+ if retry_backoff_factor < 2
567
+ raise ConfigurationError, "retry_backoff_factor must be >= 2 for exponential backoff (use 2 for standard exponential: 1s, 2s, 4s, 8s...)"
568
+ end
569
+
570
+ # Rate limit threshold validation
571
+ if rate_limit_threshold < 0 || rate_limit_threshold > 1
572
+ raise ConfigurationError, "rate_limit_threshold must be between 0.0 and 1.0"
573
+ end
574
+ end
575
+
576
+ private
577
+
578
+ def missing_api_key_message
579
+ "api_key is required. " \
580
+ "Configure in config/secapi.yml or set SECAPI_API_KEY environment variable. " \
581
+ "Get your API key from https://sec-api.io"
582
+ end
583
+
584
+ def invalid_api_key_message
585
+ "api_key appears to be invalid (placeholder or too short). " \
586
+ "Expected a valid API key from https://sec-api.io. " \
587
+ "Check your configuration in config/secapi.yml or SECAPI_API_KEY environment variable."
588
+ end
589
+ end
590
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SecApi
4
+ # Mixin module providing deep freeze functionality for immutable value objects.
5
+ #
6
+ # Include this module in Dry::Struct classes that need to ensure nested
7
+ # hashes and arrays are recursively frozen for thread-safety.
8
+ #
9
+ # @example Usage in a Dry::Struct class
10
+ # class MyObject < Dry::Struct
11
+ # include SecApi::DeepFreezable
12
+ #
13
+ # attribute :data, Types::Hash
14
+ #
15
+ # def initialize(attributes)
16
+ # super
17
+ # deep_freeze(data) if data
18
+ # freeze
19
+ # end
20
+ # end
21
+ #
22
+ module DeepFreezable
23
+ private
24
+
25
+ # Recursively freezes nested hashes and arrays.
26
+ #
27
+ # @param obj [Object] The object to freeze
28
+ # @return [void]
29
+ def deep_freeze(obj)
30
+ case obj
31
+ when Hash
32
+ obj.each_value { |v| deep_freeze(v) }
33
+ obj.freeze
34
+ when Array
35
+ obj.each { |v| deep_freeze(v) }
36
+ obj.freeze
37
+ else
38
+ obj.freeze if obj.respond_to?(:freeze) && !obj.frozen?
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SecApi
4
+ # Raised when API authentication fails (401 Unauthorized or 403 Forbidden).
5
+ #
6
+ # Why PermanentError? The API key is invalid, expired, or lacks permissions.
7
+ # This won't fix itself - human intervention required (get new key, check
8
+ # subscription, fix configuration). Retrying wastes resources and delays
9
+ # the inevitable failure. Fail fast with actionable error message.
10
+ #
11
+ # This is a permanent error - indicates an invalid or missing API key.
12
+ # Retrying won't help; the API key configuration must be fixed.
13
+ #
14
+ # @example Handling authentication errors
15
+ # begin
16
+ # client.query.ticker("AAPL").search
17
+ # rescue SecApi::AuthenticationError => e
18
+ # # Fix API key configuration
19
+ # logger.error("Authentication failed: #{e.message}")
20
+ # notify_developer("Invalid API key configuration")
21
+ # end
22
+ class AuthenticationError < PermanentError
23
+ end
24
+ end
@@ -0,0 +1,5 @@
1
+ module SecApi
2
+ # Raised when configuration is invalid or missing required values
3
+ class ConfigurationError < Error
4
+ end
5
+ end