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.
- checksums.yaml +7 -0
- data/.devcontainer/Dockerfile +54 -0
- data/.devcontainer/README.md +178 -0
- data/.devcontainer/devcontainer.json +46 -0
- data/.devcontainer/docker-compose.yml +28 -0
- data/.devcontainer/post-create.sh +51 -0
- data/.devcontainer/post-start.sh +44 -0
- data/.rspec +3 -0
- data/.standard.yml +3 -0
- data/CHANGELOG.md +5 -0
- data/CLAUDE.md +0 -0
- data/LICENSE.txt +21 -0
- data/MIGRATION.md +274 -0
- data/README.md +370 -0
- data/Rakefile +10 -0
- data/config/secapi.yml.example +57 -0
- data/docs/development-guide.md +291 -0
- data/docs/enumerator_pattern_design.md +483 -0
- data/docs/examples/README.md +58 -0
- data/docs/examples/backfill_filings.rb +419 -0
- data/docs/examples/instrumentation.rb +583 -0
- data/docs/examples/query_builder.rb +308 -0
- data/docs/examples/streaming_notifications.rb +491 -0
- data/docs/index.md +244 -0
- data/docs/migration-guide-v1.md +1091 -0
- data/docs/pre-review-checklist.md +145 -0
- data/docs/project-overview.md +90 -0
- data/docs/project-scan-report.json +60 -0
- data/docs/source-tree-analysis.md +190 -0
- data/lib/sec_api/callback_helper.rb +49 -0
- data/lib/sec_api/client.rb +606 -0
- data/lib/sec_api/collections/filings.rb +267 -0
- data/lib/sec_api/collections/fulltext_results.rb +86 -0
- data/lib/sec_api/config.rb +590 -0
- data/lib/sec_api/deep_freezable.rb +42 -0
- data/lib/sec_api/errors/authentication_error.rb +24 -0
- data/lib/sec_api/errors/configuration_error.rb +5 -0
- data/lib/sec_api/errors/error.rb +75 -0
- data/lib/sec_api/errors/network_error.rb +26 -0
- data/lib/sec_api/errors/not_found_error.rb +23 -0
- data/lib/sec_api/errors/pagination_error.rb +28 -0
- data/lib/sec_api/errors/permanent_error.rb +29 -0
- data/lib/sec_api/errors/rate_limit_error.rb +57 -0
- data/lib/sec_api/errors/reconnection_error.rb +34 -0
- data/lib/sec_api/errors/server_error.rb +25 -0
- data/lib/sec_api/errors/transient_error.rb +28 -0
- data/lib/sec_api/errors/validation_error.rb +23 -0
- data/lib/sec_api/extractor.rb +122 -0
- data/lib/sec_api/filing_journey.rb +477 -0
- data/lib/sec_api/mapping.rb +125 -0
- data/lib/sec_api/metrics_collector.rb +411 -0
- data/lib/sec_api/middleware/error_handler.rb +250 -0
- data/lib/sec_api/middleware/instrumentation.rb +186 -0
- data/lib/sec_api/middleware/rate_limiter.rb +541 -0
- data/lib/sec_api/objects/data_file.rb +34 -0
- data/lib/sec_api/objects/document_format_file.rb +45 -0
- data/lib/sec_api/objects/entity.rb +92 -0
- data/lib/sec_api/objects/extracted_data.rb +118 -0
- data/lib/sec_api/objects/fact.rb +147 -0
- data/lib/sec_api/objects/filing.rb +197 -0
- data/lib/sec_api/objects/fulltext_result.rb +66 -0
- data/lib/sec_api/objects/period.rb +96 -0
- data/lib/sec_api/objects/stream_filing.rb +194 -0
- data/lib/sec_api/objects/xbrl_data.rb +356 -0
- data/lib/sec_api/query.rb +423 -0
- data/lib/sec_api/rate_limit_state.rb +130 -0
- data/lib/sec_api/rate_limit_tracker.rb +154 -0
- data/lib/sec_api/stream.rb +841 -0
- data/lib/sec_api/structured_logger.rb +199 -0
- data/lib/sec_api/types.rb +32 -0
- data/lib/sec_api/version.rb +42 -0
- data/lib/sec_api/xbrl.rb +220 -0
- data/lib/sec_api.rb +137 -0
- data/sig/sec_api.rbs +4 -0
- 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
|