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,606 @@
|
|
|
1
|
+
require "faraday"
|
|
2
|
+
require "faraday/retry"
|
|
3
|
+
|
|
4
|
+
module SecApi
|
|
5
|
+
# Main entry point for interacting with the sec-api.io API.
|
|
6
|
+
#
|
|
7
|
+
# The Client manages HTTP connections, configuration, and provides access to
|
|
8
|
+
# specialized proxy objects for different API endpoints. It uses a Client-Proxy
|
|
9
|
+
# architecture where the Client handles connection lifecycle and configuration,
|
|
10
|
+
# while proxy objects handle domain-specific operations.
|
|
11
|
+
#
|
|
12
|
+
# @example Create a client with API key
|
|
13
|
+
# client = SecApi::Client.new(api_key: "your_api_key")
|
|
14
|
+
#
|
|
15
|
+
# @example Create a client with full configuration
|
|
16
|
+
# config = SecApi::Config.new(
|
|
17
|
+
# api_key: "your_api_key",
|
|
18
|
+
# base_url: "https://api.sec-api.io",
|
|
19
|
+
# request_timeout: 60,
|
|
20
|
+
# retry_max_attempts: 5,
|
|
21
|
+
# default_logging: true,
|
|
22
|
+
# logger: Rails.logger
|
|
23
|
+
# )
|
|
24
|
+
# client = SecApi::Client.new(config)
|
|
25
|
+
#
|
|
26
|
+
# @example Access different API endpoints via proxies
|
|
27
|
+
# client.query # => SecApi::Query (filing searches)
|
|
28
|
+
# client.mapping # => SecApi::Mapping (entity resolution)
|
|
29
|
+
# client.extractor # => SecApi::Extractor (document extraction)
|
|
30
|
+
# client.xbrl # => SecApi::Xbrl (XBRL data extraction)
|
|
31
|
+
# client.stream # => SecApi::Stream (real-time WebSocket)
|
|
32
|
+
#
|
|
33
|
+
# @example Monitor rate limit status
|
|
34
|
+
# summary = client.rate_limit_summary
|
|
35
|
+
# puts "Remaining: #{summary[:remaining]}/#{summary[:limit]}"
|
|
36
|
+
# puts "Queued: #{summary[:queued_count]}"
|
|
37
|
+
#
|
|
38
|
+
# @note Client instances are thread-safe. All response objects are immutable
|
|
39
|
+
# (using Dry::Struct) and can be safely shared between threads.
|
|
40
|
+
#
|
|
41
|
+
# @see SecApi::Config Configuration options
|
|
42
|
+
# @see SecApi::Query Fluent query builder
|
|
43
|
+
# @see SecApi::Mapping Entity resolution
|
|
44
|
+
# @see SecApi::Extractor Document extraction
|
|
45
|
+
# @see SecApi::Xbrl XBRL data extraction
|
|
46
|
+
# @see SecApi::Stream Real-time WebSocket streaming
|
|
47
|
+
#
|
|
48
|
+
class Client
|
|
49
|
+
include CallbackHelper
|
|
50
|
+
|
|
51
|
+
# Creates a new SEC API client.
|
|
52
|
+
#
|
|
53
|
+
# @param config [SecApi::Config, Hash] Configuration object or hash with config options.
|
|
54
|
+
# If a Hash is provided, it's passed to Config.new. If omitted, uses environment
|
|
55
|
+
# variables and defaults.
|
|
56
|
+
# @option config [String] :api_key SEC API key (required, or set SECAPI_API_KEY env var)
|
|
57
|
+
# @option config [String] :base_url Base API URL (default: "https://api.sec-api.io")
|
|
58
|
+
# @option config [Integer] :request_timeout Request timeout in seconds (default: 30)
|
|
59
|
+
# @option config [Integer] :retry_max_attempts Maximum retry attempts (default: 3)
|
|
60
|
+
# @option config [Boolean] :default_logging Enable default structured logging (default: false)
|
|
61
|
+
# @option config [Logger] :logger Logger instance for logging events
|
|
62
|
+
#
|
|
63
|
+
# @return [SecApi::Client] Configured client instance
|
|
64
|
+
#
|
|
65
|
+
# @raise [SecApi::ConfigurationError] when api_key is missing, a placeholder value,
|
|
66
|
+
# or too short (< 10 characters)
|
|
67
|
+
#
|
|
68
|
+
# @example Create with API key only
|
|
69
|
+
# client = SecApi::Client.new(api_key: "your_api_key")
|
|
70
|
+
#
|
|
71
|
+
# @example Create with environment variable
|
|
72
|
+
# # Set SECAPI_API_KEY environment variable
|
|
73
|
+
# client = SecApi::Client.new
|
|
74
|
+
#
|
|
75
|
+
# @example Create with full configuration
|
|
76
|
+
# client = SecApi::Client.new(
|
|
77
|
+
# api_key: "your_api_key",
|
|
78
|
+
# request_timeout: 60,
|
|
79
|
+
# retry_max_attempts: 5,
|
|
80
|
+
# default_logging: true,
|
|
81
|
+
# logger: Logger.new($stdout)
|
|
82
|
+
# )
|
|
83
|
+
#
|
|
84
|
+
def initialize(config = Config.new)
|
|
85
|
+
@_config = config
|
|
86
|
+
@_config.validate!
|
|
87
|
+
setup_default_logging if @_config.default_logging && @_config.logger
|
|
88
|
+
setup_default_metrics if @_config.metrics_backend
|
|
89
|
+
@_rate_limit_tracker = RateLimitTracker.new
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Returns the configuration object for this client.
|
|
93
|
+
#
|
|
94
|
+
# @return [SecApi::Config] The client's configuration
|
|
95
|
+
#
|
|
96
|
+
# @example Access configuration settings
|
|
97
|
+
# client.config.api_key # => "your_api_key"
|
|
98
|
+
# client.config.base_url # => "https://api.sec-api.io"
|
|
99
|
+
# client.config.request_timeout # => 30
|
|
100
|
+
#
|
|
101
|
+
def config
|
|
102
|
+
@_config
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Returns the Faraday connection used for HTTP requests.
|
|
106
|
+
#
|
|
107
|
+
# The connection is lazily initialized on first access and includes the full
|
|
108
|
+
# middleware stack: JSON encoding/decoding, instrumentation, retry logic,
|
|
109
|
+
# rate limiting, and error handling.
|
|
110
|
+
#
|
|
111
|
+
# @return [Faraday::Connection] Configured Faraday connection
|
|
112
|
+
#
|
|
113
|
+
# @example Make a direct request (advanced usage)
|
|
114
|
+
# response = client.connection.get("/mapping/ticker/AAPL")
|
|
115
|
+
# puts response.body
|
|
116
|
+
#
|
|
117
|
+
# @note In most cases, use the proxy objects (query, mapping, etc.) instead
|
|
118
|
+
# of making direct connection requests. The proxies provide type-safe
|
|
119
|
+
# response handling and a cleaner API.
|
|
120
|
+
#
|
|
121
|
+
def connection
|
|
122
|
+
@_connection ||= build_connection
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Returns a fresh Query builder instance for constructing SEC filing searches.
|
|
126
|
+
#
|
|
127
|
+
# Unlike other proxy methods, this returns a NEW instance on each call
|
|
128
|
+
# to ensure query chains start with fresh state.
|
|
129
|
+
#
|
|
130
|
+
# @return [SecApi::Query] Fresh query builder instance
|
|
131
|
+
#
|
|
132
|
+
# @example Each call starts fresh
|
|
133
|
+
# client.query.ticker("AAPL").search # Query: "ticker:AAPL"
|
|
134
|
+
# client.query.ticker("TSLA").search # Query: "ticker:TSLA" (not "ticker:AAPL AND ticker:TSLA")
|
|
135
|
+
#
|
|
136
|
+
def query
|
|
137
|
+
Query.new(self)
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# Returns the Extractor proxy for document extraction functionality.
|
|
141
|
+
#
|
|
142
|
+
# Provides access to the sec-api.io document extraction API for extracting
|
|
143
|
+
# text and specific sections from SEC filings.
|
|
144
|
+
#
|
|
145
|
+
# @return [SecApi::Extractor] Extractor proxy instance
|
|
146
|
+
#
|
|
147
|
+
# @example Extract full filing text
|
|
148
|
+
# filing = client.query.ticker("AAPL").form_type("10-K").search.first
|
|
149
|
+
# extracted = client.extractor.extract(filing.url)
|
|
150
|
+
# puts extracted.text
|
|
151
|
+
#
|
|
152
|
+
# @example Extract specific sections
|
|
153
|
+
# extracted = client.extractor.extract(filing.url, sections: [:risk_factors, :mda])
|
|
154
|
+
# puts extracted.risk_factors
|
|
155
|
+
# puts extracted.mda
|
|
156
|
+
#
|
|
157
|
+
# @see SecApi::Extractor
|
|
158
|
+
#
|
|
159
|
+
def extractor
|
|
160
|
+
@_extractor ||= Extractor.new(self)
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# Returns the Mapping proxy for entity resolution functionality.
|
|
164
|
+
#
|
|
165
|
+
# Provides access to the sec-api.io mapping API for resolving between
|
|
166
|
+
# different entity identifiers: ticker symbols, CIK numbers, CUSIP, and
|
|
167
|
+
# company names.
|
|
168
|
+
#
|
|
169
|
+
# @return [SecApi::Mapping] Mapping proxy instance
|
|
170
|
+
#
|
|
171
|
+
# @example Resolve ticker to company entity
|
|
172
|
+
# entity = client.mapping.ticker("AAPL")
|
|
173
|
+
# puts "CIK: #{entity.cik}, Name: #{entity.name}"
|
|
174
|
+
#
|
|
175
|
+
# @example Resolve CIK to ticker
|
|
176
|
+
# entity = client.mapping.cik("320193")
|
|
177
|
+
# puts "Ticker: #{entity.ticker}"
|
|
178
|
+
#
|
|
179
|
+
# @example Resolve CUSIP
|
|
180
|
+
# entity = client.mapping.cusip("037833100")
|
|
181
|
+
#
|
|
182
|
+
# @see SecApi::Mapping
|
|
183
|
+
#
|
|
184
|
+
def mapping
|
|
185
|
+
@_mapping ||= Mapping.new(self)
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
# Returns the XBRL extraction proxy for accessing XBRL-to-JSON conversion functionality.
|
|
189
|
+
#
|
|
190
|
+
# @return [SecApi::Xbrl] XBRL proxy instance with access to client's Faraday connection
|
|
191
|
+
#
|
|
192
|
+
# @example Extract XBRL data from a filing
|
|
193
|
+
# client = SecApi::Client.new(api_key: "your_api_key")
|
|
194
|
+
# xbrl_data = client.xbrl.to_json(filing)
|
|
195
|
+
# xbrl_data.financials[:revenue] # => 394328000000.0
|
|
196
|
+
#
|
|
197
|
+
def xbrl
|
|
198
|
+
@_xbrl ||= Xbrl.new(self)
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
# Returns the Stream proxy for real-time filing notifications via WebSocket.
|
|
202
|
+
#
|
|
203
|
+
# @return [SecApi::Stream] Stream proxy instance for WebSocket subscriptions
|
|
204
|
+
#
|
|
205
|
+
# @example Subscribe to real-time filings
|
|
206
|
+
# client = SecApi::Client.new
|
|
207
|
+
# client.stream.subscribe do |filing|
|
|
208
|
+
# puts "New filing: #{filing.ticker} - #{filing.form_type}"
|
|
209
|
+
# end
|
|
210
|
+
#
|
|
211
|
+
# @example Close the streaming connection
|
|
212
|
+
# client.stream.close
|
|
213
|
+
#
|
|
214
|
+
# @note The subscribe method blocks while receiving events.
|
|
215
|
+
# For non-blocking operation, run in a separate thread.
|
|
216
|
+
#
|
|
217
|
+
def stream
|
|
218
|
+
@_stream ||= Stream.new(self)
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
# Returns the current rate limit state from the most recent API response.
|
|
222
|
+
#
|
|
223
|
+
# The state is automatically updated after each API request based on
|
|
224
|
+
# X-RateLimit-* headers returned by sec-api.io.
|
|
225
|
+
#
|
|
226
|
+
# @return [RateLimitState, nil] The current rate limit state, or nil if no
|
|
227
|
+
# rate limit headers have been received yet
|
|
228
|
+
#
|
|
229
|
+
# @example Check rate limit status after requests
|
|
230
|
+
# client = SecApi::Client.new
|
|
231
|
+
# client.query.ticker("AAPL").search
|
|
232
|
+
#
|
|
233
|
+
# state = client.rate_limit_state
|
|
234
|
+
# puts "Remaining: #{state.remaining}/#{state.limit}"
|
|
235
|
+
# puts "Resets at: #{state.reset_at}"
|
|
236
|
+
#
|
|
237
|
+
# @example Proactive throttling based on remaining quota
|
|
238
|
+
# state = client.rate_limit_state
|
|
239
|
+
# if state&.percentage_remaining && state.percentage_remaining < 10
|
|
240
|
+
# # Less than 10% remaining, consider slowing down
|
|
241
|
+
# sleep(1)
|
|
242
|
+
# end
|
|
243
|
+
#
|
|
244
|
+
# @example Handle exhausted rate limit
|
|
245
|
+
# if client.rate_limit_state&.exhausted?
|
|
246
|
+
# wait_time = client.rate_limit_state.reset_at - Time.now
|
|
247
|
+
# sleep(wait_time) if wait_time.positive?
|
|
248
|
+
# end
|
|
249
|
+
#
|
|
250
|
+
def rate_limit_state
|
|
251
|
+
@_rate_limit_tracker.current_state
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
# Returns the number of requests currently queued waiting for rate limit reset.
|
|
255
|
+
#
|
|
256
|
+
# When the rate limit is exhausted (remaining = 0), incoming requests are
|
|
257
|
+
# queued until the rate limit window resets. This method returns the current
|
|
258
|
+
# count of waiting requests, useful for monitoring and debugging.
|
|
259
|
+
#
|
|
260
|
+
# @return [Integer] Number of requests currently waiting in queue
|
|
261
|
+
#
|
|
262
|
+
# @example Monitor queue depth
|
|
263
|
+
# client = SecApi::Client.new
|
|
264
|
+
# # During heavy load when rate limited:
|
|
265
|
+
# puts "#{client.queued_requests} requests waiting"
|
|
266
|
+
#
|
|
267
|
+
# @example Logging queue status
|
|
268
|
+
# config = SecApi::Config.new(
|
|
269
|
+
# api_key: "...",
|
|
270
|
+
# on_queue: ->(info) {
|
|
271
|
+
# puts "Request queued (#{info[:queue_size]} total waiting)"
|
|
272
|
+
# }
|
|
273
|
+
# )
|
|
274
|
+
#
|
|
275
|
+
def queued_requests
|
|
276
|
+
@_rate_limit_tracker.queued_count
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
# Returns a summary of the current rate limit state for debugging and monitoring.
|
|
280
|
+
#
|
|
281
|
+
# Provides a comprehensive view of the rate limit status in a single method call,
|
|
282
|
+
# useful for debugging, logging, and monitoring dashboards.
|
|
283
|
+
#
|
|
284
|
+
# @return [Hash] Rate limit summary with the following keys:
|
|
285
|
+
# - :remaining [Integer, nil] - Requests remaining in current window
|
|
286
|
+
# - :limit [Integer, nil] - Total requests allowed per window
|
|
287
|
+
# - :percentage [Float, nil] - Percentage of quota remaining (0.0-100.0)
|
|
288
|
+
# - :reset_at [Time, nil] - When the rate limit window resets
|
|
289
|
+
# - :queued_count [Integer] - Number of requests currently queued
|
|
290
|
+
# - :exhausted [Boolean] - True if rate limit is exhausted (remaining = 0)
|
|
291
|
+
#
|
|
292
|
+
# @example Quick debugging
|
|
293
|
+
# client = SecApi::Client.new
|
|
294
|
+
# client.query.ticker("AAPL").search
|
|
295
|
+
#
|
|
296
|
+
# pp client.rate_limit_summary
|
|
297
|
+
# # => {:remaining=>95, :limit=>100, :percentage=>95.0,
|
|
298
|
+
# # :reset_at=>2024-01-15 10:30:00 +0000, :queued_count=>0, :exhausted=>false}
|
|
299
|
+
#
|
|
300
|
+
# @example Health check endpoint
|
|
301
|
+
# get '/health/rate_limit' do
|
|
302
|
+
# json client.rate_limit_summary
|
|
303
|
+
# end
|
|
304
|
+
#
|
|
305
|
+
def rate_limit_summary
|
|
306
|
+
state = rate_limit_state
|
|
307
|
+
{
|
|
308
|
+
remaining: state&.remaining,
|
|
309
|
+
limit: state&.limit,
|
|
310
|
+
percentage: state&.percentage_remaining,
|
|
311
|
+
reset_at: state&.reset_at,
|
|
312
|
+
queued_count: queued_requests,
|
|
313
|
+
exhausted: state&.exhausted? || false
|
|
314
|
+
}
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
private
|
|
318
|
+
|
|
319
|
+
# Middleware Stack Order (Architecture ADR-3: Critical Design Decision)
|
|
320
|
+
#
|
|
321
|
+
# Faraday middleware executes in REVERSE registration order for requests,
|
|
322
|
+
# and FORWARD order for responses. Our stack:
|
|
323
|
+
#
|
|
324
|
+
# Request flow: Instrumentation → Retry → RateLimiter → ErrorHandler → Adapter
|
|
325
|
+
# Response flow: Adapter → ErrorHandler → RateLimiter → Retry → Instrumentation
|
|
326
|
+
#
|
|
327
|
+
# Why this order?
|
|
328
|
+
# 1. Instrumentation FIRST: Captures timing for all requests including retries
|
|
329
|
+
# 2. Retry BEFORE ErrorHandler: Can catch HTTP 429/5xx before they become exceptions
|
|
330
|
+
# 3. RateLimiter AFTER Retry: Sees final response headers (not intermediate retry responses)
|
|
331
|
+
# 4. ErrorHandler LAST: Converts HTTP errors to typed exceptions for caller
|
|
332
|
+
#
|
|
333
|
+
# Moving middleware out of order breaks the resilience guarantees!
|
|
334
|
+
def build_connection
|
|
335
|
+
Faraday.new(url: @_config.base_url) do |conn|
|
|
336
|
+
# Set API key in Authorization header (redacted from Faraday logs automatically)
|
|
337
|
+
conn.headers["Authorization"] = @_config.api_key
|
|
338
|
+
conn.options.timeout = @_config.request_timeout
|
|
339
|
+
|
|
340
|
+
# JSON encoding/decoding
|
|
341
|
+
conn.request :json
|
|
342
|
+
conn.response :json, content_type: /\bjson$/, parser_options: {symbolize_names: true}
|
|
343
|
+
|
|
344
|
+
# Instrumentation middleware - positioned FIRST to capture all requests/responses
|
|
345
|
+
# including retried requests. Generates request_id for correlation.
|
|
346
|
+
# Invokes on_request (before request) and on_response (after response) callbacks.
|
|
347
|
+
conn.use Middleware::Instrumentation, config: @_config
|
|
348
|
+
|
|
349
|
+
# Retry middleware - positioned BEFORE ErrorHandler to catch HTTP status codes
|
|
350
|
+
# Retries on [429, 500, 502, 503, 504] and Faraday exceptions
|
|
351
|
+
conn.request :retry, retry_options
|
|
352
|
+
|
|
353
|
+
# Rate limiter middleware - extracts X-RateLimit-* headers from responses,
|
|
354
|
+
# proactively throttles when approaching rate limits, and queues requests
|
|
355
|
+
# when rate limit is exhausted (remaining = 0).
|
|
356
|
+
# Positioned after Retry to capture final response headers (not intermediate retries)
|
|
357
|
+
# Positioned before ErrorHandler to capture headers even from 429 responses
|
|
358
|
+
conn.use Middleware::RateLimiter,
|
|
359
|
+
state_store: @_rate_limit_tracker,
|
|
360
|
+
threshold: @_config.rate_limit_threshold,
|
|
361
|
+
queue_wait_warning_threshold: @_config.queue_wait_warning_threshold,
|
|
362
|
+
on_throttle: @_config.on_throttle,
|
|
363
|
+
on_queue: @_config.on_queue,
|
|
364
|
+
on_dequeue: @_config.on_dequeue,
|
|
365
|
+
on_excessive_wait: @_config.on_excessive_wait,
|
|
366
|
+
logger: @_config.logger,
|
|
367
|
+
log_level: @_config.log_level
|
|
368
|
+
|
|
369
|
+
# Error handler middleware - converts HTTP errors to typed exceptions
|
|
370
|
+
# Positioned AFTER retry so non-retryable errors (401, 404, etc.) fail immediately
|
|
371
|
+
# Invokes on_error callback when raising errors (Story 7.1)
|
|
372
|
+
conn.use Middleware::ErrorHandler, config: @_config
|
|
373
|
+
|
|
374
|
+
# Connection pool configuration (NFR14: minimum 10 concurrent requests)
|
|
375
|
+
# Note: Net::HTTP adapter uses persistent connections but doesn't expose pool_size config
|
|
376
|
+
# The adapter handles concurrent requests via Ruby's thread-safe HTTP implementation
|
|
377
|
+
conn.adapter Faraday.default_adapter
|
|
378
|
+
end
|
|
379
|
+
end
|
|
380
|
+
|
|
381
|
+
# Builds retry configuration options for faraday-retry middleware.
|
|
382
|
+
#
|
|
383
|
+
# The retry middleware handles transient failures with exponential backoff.
|
|
384
|
+
# faraday-retry automatically respects Retry-After headers from 429 responses.
|
|
385
|
+
# When Retry-After is absent but X-RateLimit-Reset is present, the middleware
|
|
386
|
+
# calculates the delay from the reset timestamp.
|
|
387
|
+
#
|
|
388
|
+
# @return [Hash] Configuration options for Faraday::Retry::Middleware
|
|
389
|
+
# @api private
|
|
390
|
+
def retry_options
|
|
391
|
+
{
|
|
392
|
+
max: @_config.retry_max_attempts,
|
|
393
|
+
interval: @_config.retry_initial_delay,
|
|
394
|
+
max_interval: @_config.retry_max_delay,
|
|
395
|
+
backoff_factor: @_config.retry_backoff_factor,
|
|
396
|
+
exceptions: [
|
|
397
|
+
Faraday::TimeoutError,
|
|
398
|
+
Faraday::ConnectionFailed,
|
|
399
|
+
Faraday::SSLError,
|
|
400
|
+
# Catch our typed TransientError exceptions and retry them
|
|
401
|
+
SecApi::TransientError
|
|
402
|
+
],
|
|
403
|
+
methods: [:get, :post],
|
|
404
|
+
retry_statuses: [429, 500, 502, 503, 504],
|
|
405
|
+
# Custom retry logic for RateLimitError with reset_at timestamp
|
|
406
|
+
# When Retry-After header is absent but X-RateLimit-Reset is present,
|
|
407
|
+
# calculate the delay from the reset timestamp
|
|
408
|
+
retry_if: ->(env, exception) {
|
|
409
|
+
calculate_rate_limit_interval(env, exception)
|
|
410
|
+
true # Always allow retry for transient errors
|
|
411
|
+
},
|
|
412
|
+
retry_block: ->(env:, options:, retry_count:, exception:, will_retry_in:) {
|
|
413
|
+
# Called before EACH retry attempt
|
|
414
|
+
# Invoke on_rate_limit callback for 429 responses
|
|
415
|
+
invoke_on_rate_limit_callback(exception, retry_count, env)
|
|
416
|
+
|
|
417
|
+
# Invoke on_retry callback for retry instrumentation (Story 7.1)
|
|
418
|
+
invoke_on_retry_callback(
|
|
419
|
+
env: env,
|
|
420
|
+
retry_count: retry_count,
|
|
421
|
+
max_attempts: options[:max],
|
|
422
|
+
exception: exception,
|
|
423
|
+
will_retry_in: will_retry_in
|
|
424
|
+
)
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
end
|
|
428
|
+
|
|
429
|
+
# Calculates and sets retry interval based on RateLimitError reset_at.
|
|
430
|
+
#
|
|
431
|
+
# When a RateLimitError has a reset_at timestamp but no retry_after,
|
|
432
|
+
# this method calculates the delay from the reset timestamp and stores
|
|
433
|
+
# it in env[:retry_interval] for the retry middleware to use.
|
|
434
|
+
#
|
|
435
|
+
# @param env [Hash] Faraday request environment
|
|
436
|
+
# @param exception [Exception] The exception that triggered the retry
|
|
437
|
+
# @return [void]
|
|
438
|
+
# @api private
|
|
439
|
+
def calculate_rate_limit_interval(env, exception)
|
|
440
|
+
return unless exception.is_a?(SecApi::RateLimitError)
|
|
441
|
+
return if exception.retry_after # Retry-After takes precedence
|
|
442
|
+
|
|
443
|
+
if exception.reset_at
|
|
444
|
+
delay = exception.reset_at - Time.now
|
|
445
|
+
env[:retry_interval] = delay.clamp(1, @_config.retry_max_delay) if delay.positive?
|
|
446
|
+
end
|
|
447
|
+
end
|
|
448
|
+
|
|
449
|
+
# Invokes the on_rate_limit callback for RateLimitError exceptions and logs the event.
|
|
450
|
+
#
|
|
451
|
+
# @param exception [Exception] The exception that triggered the retry
|
|
452
|
+
# @param retry_count [Integer] Zero-indexed retry count from faraday-retry
|
|
453
|
+
# @param env [Hash] Faraday request environment containing request_id
|
|
454
|
+
# @return [void]
|
|
455
|
+
# @api private
|
|
456
|
+
def invoke_on_rate_limit_callback(exception, retry_count, env = {})
|
|
457
|
+
return unless exception.is_a?(SecApi::RateLimitError)
|
|
458
|
+
|
|
459
|
+
log_rate_limit_exceeded(exception, retry_count, env)
|
|
460
|
+
|
|
461
|
+
return unless @_config.respond_to?(:on_rate_limit) && @_config.on_rate_limit
|
|
462
|
+
|
|
463
|
+
@_config.on_rate_limit.call({
|
|
464
|
+
retry_after: exception.retry_after,
|
|
465
|
+
reset_at: exception.reset_at,
|
|
466
|
+
attempt: retry_count + 1, # Convert 0-indexed to 1-indexed for user convenience
|
|
467
|
+
request_id: env[:request_id]
|
|
468
|
+
})
|
|
469
|
+
end
|
|
470
|
+
|
|
471
|
+
# Logs a 429 rate limit exceeded event with structured data.
|
|
472
|
+
#
|
|
473
|
+
# @param exception [RateLimitError] The rate limit exception
|
|
474
|
+
# @param retry_count [Integer] Zero-indexed retry count
|
|
475
|
+
# @param env [Hash] Faraday request environment
|
|
476
|
+
# @return [void]
|
|
477
|
+
# @api private
|
|
478
|
+
def log_rate_limit_exceeded(exception, retry_count, env)
|
|
479
|
+
return unless @_config.logger
|
|
480
|
+
|
|
481
|
+
log_data = {
|
|
482
|
+
event: "secapi.rate_limit.exceeded",
|
|
483
|
+
request_id: env[:request_id],
|
|
484
|
+
retry_after: exception.retry_after,
|
|
485
|
+
reset_at: exception.reset_at&.iso8601,
|
|
486
|
+
attempt: retry_count + 1
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
begin
|
|
490
|
+
@_config.logger.send(@_config.log_level) { log_data.to_json }
|
|
491
|
+
rescue
|
|
492
|
+
# Don't let logging errors break the request
|
|
493
|
+
end
|
|
494
|
+
end
|
|
495
|
+
|
|
496
|
+
# Invokes the on_retry callback for retry instrumentation.
|
|
497
|
+
#
|
|
498
|
+
# @param env [Hash] Faraday request environment
|
|
499
|
+
# @param retry_count [Integer] Zero-indexed retry count from faraday-retry
|
|
500
|
+
# @param max_attempts [Integer] Maximum retry attempts configured
|
|
501
|
+
# @param exception [Exception] The exception that triggered the retry
|
|
502
|
+
# @param will_retry_in [Float] Seconds until retry
|
|
503
|
+
# @return [void]
|
|
504
|
+
# @api private
|
|
505
|
+
def invoke_on_retry_callback(env:, retry_count:, max_attempts:, exception:, will_retry_in:)
|
|
506
|
+
return unless @_config.on_retry
|
|
507
|
+
|
|
508
|
+
@_config.on_retry.call(
|
|
509
|
+
request_id: env[:request_id],
|
|
510
|
+
attempt: retry_count + 1, # Convert 0-indexed to 1-indexed for user convenience
|
|
511
|
+
max_attempts: max_attempts,
|
|
512
|
+
error_class: exception.class.name,
|
|
513
|
+
error_message: exception.message,
|
|
514
|
+
will_retry_in: will_retry_in
|
|
515
|
+
)
|
|
516
|
+
rescue => e
|
|
517
|
+
log_callback_error("on_retry", e)
|
|
518
|
+
end
|
|
519
|
+
|
|
520
|
+
# log_callback_error is provided by CallbackHelper module
|
|
521
|
+
|
|
522
|
+
# Sets up default structured logging callbacks when default_logging is enabled.
|
|
523
|
+
#
|
|
524
|
+
# Uses {SecApi::StructuredLogger} to generate JSON-formatted log events
|
|
525
|
+
# for request lifecycle events. Explicit callbacks configured by the user
|
|
526
|
+
# take precedence and will not be overridden.
|
|
527
|
+
#
|
|
528
|
+
# @return [void]
|
|
529
|
+
# @api private
|
|
530
|
+
def setup_default_logging
|
|
531
|
+
logger = @_config.logger
|
|
532
|
+
level = @_config.log_level || :info
|
|
533
|
+
|
|
534
|
+
# Only set callbacks that aren't already configured
|
|
535
|
+
@_config.on_request ||= ->(request_id:, method:, url:, **) {
|
|
536
|
+
StructuredLogger.log_request(logger, level,
|
|
537
|
+
request_id: request_id, method: method, url: url)
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
@_config.on_response ||= ->(request_id:, status:, duration_ms:, url:, method:) {
|
|
541
|
+
StructuredLogger.log_response(logger, level,
|
|
542
|
+
request_id: request_id, status: status, duration_ms: duration_ms, url: url, method: method)
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
@_config.on_retry ||= ->(request_id:, attempt:, max_attempts:, error_class:, error_message:, will_retry_in:) {
|
|
546
|
+
StructuredLogger.log_retry(logger, :warn, # Always warn on retry
|
|
547
|
+
request_id: request_id, attempt: attempt, max_attempts: max_attempts,
|
|
548
|
+
error_class: error_class, error_message: error_message, will_retry_in: will_retry_in)
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
@_config.on_error ||= ->(request_id:, error:, url:, method:) {
|
|
552
|
+
StructuredLogger.log_error(logger, :error, # Always error on failure
|
|
553
|
+
request_id: request_id, error: error, url: url, method: method)
|
|
554
|
+
}
|
|
555
|
+
end
|
|
556
|
+
|
|
557
|
+
# Sets up default metrics callbacks when metrics_backend is configured.
|
|
558
|
+
#
|
|
559
|
+
# Uses {SecApi::MetricsCollector} to record metrics for all request
|
|
560
|
+
# lifecycle events. Explicit callbacks configured by the user take
|
|
561
|
+
# precedence and will not be overridden.
|
|
562
|
+
#
|
|
563
|
+
# @return [void]
|
|
564
|
+
# @api private
|
|
565
|
+
def setup_default_metrics
|
|
566
|
+
backend = @_config.metrics_backend
|
|
567
|
+
|
|
568
|
+
# Only set callbacks that aren't already configured
|
|
569
|
+
@_config.on_response ||= ->(request_id:, status:, duration_ms:, url:, method:) {
|
|
570
|
+
MetricsCollector.record_response(backend,
|
|
571
|
+
status: status, duration_ms: duration_ms, method: method)
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
@_config.on_retry ||= ->(request_id:, attempt:, max_attempts:, error_class:, error_message:, will_retry_in:) {
|
|
575
|
+
MetricsCollector.record_retry(backend,
|
|
576
|
+
attempt: attempt, error_class: error_class)
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
@_config.on_error ||= ->(request_id:, error:, url:, method:) {
|
|
580
|
+
MetricsCollector.record_error(backend,
|
|
581
|
+
error_class: error.class.name, method: method)
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
@_config.on_rate_limit ||= ->(info) {
|
|
585
|
+
MetricsCollector.record_rate_limit(backend,
|
|
586
|
+
retry_after: info[:retry_after])
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
@_config.on_throttle ||= ->(info) {
|
|
590
|
+
MetricsCollector.record_throttle(backend,
|
|
591
|
+
remaining: info[:remaining], delay: info[:delay])
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
# Streaming callbacks (record_filing, record_reconnect)
|
|
595
|
+
@_config.on_filing ||= ->(filing:, latency_ms:, received_at:) {
|
|
596
|
+
MetricsCollector.record_filing(backend,
|
|
597
|
+
latency_ms: latency_ms, form_type: filing.form_type)
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
@_config.on_reconnect ||= ->(info) {
|
|
601
|
+
MetricsCollector.record_reconnect(backend,
|
|
602
|
+
attempt_count: info[:attempt_count], downtime_seconds: info[:downtime_seconds])
|
|
603
|
+
}
|
|
604
|
+
end
|
|
605
|
+
end
|
|
606
|
+
end
|