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,541 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "faraday"
|
|
4
|
+
require "securerandom"
|
|
5
|
+
require "json"
|
|
6
|
+
|
|
7
|
+
module SecApi
|
|
8
|
+
module Middleware
|
|
9
|
+
# Faraday middleware that extracts rate limit headers from API responses,
|
|
10
|
+
# proactively throttles requests when approaching the rate limit, and
|
|
11
|
+
# queues requests when the rate limit is exhausted.
|
|
12
|
+
#
|
|
13
|
+
# This middleware parses X-RateLimit-* headers from sec-api.io responses
|
|
14
|
+
# and updates a shared state store with the current rate limit information.
|
|
15
|
+
# When the remaining quota drops below a configurable threshold, the middleware
|
|
16
|
+
# will sleep until the rate limit window resets to avoid hitting 429 errors.
|
|
17
|
+
# When remaining reaches 0 (exhausted), requests are queued until reset.
|
|
18
|
+
#
|
|
19
|
+
# Headers parsed:
|
|
20
|
+
# - X-RateLimit-Limit: Total requests allowed per time window
|
|
21
|
+
# - X-RateLimit-Remaining: Requests remaining in current window
|
|
22
|
+
# - X-RateLimit-Reset: Unix timestamp when the limit resets
|
|
23
|
+
#
|
|
24
|
+
# Position in middleware stack: After Retry, before ErrorHandler
|
|
25
|
+
# This ensures we capture headers from the final response (after retries)
|
|
26
|
+
# and can extract rate limit info even from error responses (429).
|
|
27
|
+
#
|
|
28
|
+
# @example Middleware stack integration
|
|
29
|
+
# Faraday.new(url: base_url) do |conn|
|
|
30
|
+
# conn.request :retry, retry_options
|
|
31
|
+
# conn.use SecApi::Middleware::RateLimiter,
|
|
32
|
+
# state_store: tracker,
|
|
33
|
+
# threshold: 0.1 # Throttle when < 10% remaining
|
|
34
|
+
# conn.use SecApi::Middleware::ErrorHandler
|
|
35
|
+
# conn.adapter Faraday.default_adapter
|
|
36
|
+
# end
|
|
37
|
+
#
|
|
38
|
+
# @see SecApi::RateLimitTracker Thread-safe state storage
|
|
39
|
+
# @see SecApi::RateLimitState Immutable state value object
|
|
40
|
+
#
|
|
41
|
+
class RateLimiter < Faraday::Middleware
|
|
42
|
+
# Header name for total requests allowed per time window.
|
|
43
|
+
# @return [String] lowercase header name
|
|
44
|
+
LIMIT_HEADER = "x-ratelimit-limit"
|
|
45
|
+
|
|
46
|
+
# Header name for requests remaining in current window.
|
|
47
|
+
# @return [String] lowercase header name
|
|
48
|
+
REMAINING_HEADER = "x-ratelimit-remaining"
|
|
49
|
+
|
|
50
|
+
# Header name for Unix timestamp when the limit resets.
|
|
51
|
+
# @return [String] lowercase header name
|
|
52
|
+
RESET_HEADER = "x-ratelimit-reset"
|
|
53
|
+
|
|
54
|
+
# Default throttle threshold (10% remaining).
|
|
55
|
+
# Rationale: 10% provides a safety buffer to avoid hitting 429 while not being overly
|
|
56
|
+
# conservative. At typical sec-api.io limits (~100 req/min), 10% = 10 requests buffer,
|
|
57
|
+
# which handles small bursts. Lower values risk 429s; higher values waste capacity.
|
|
58
|
+
# (Architecture ADR-4: Rate Limiting Strategy)
|
|
59
|
+
DEFAULT_THRESHOLD = 0.1
|
|
60
|
+
|
|
61
|
+
# Default warning threshold for excessive wait times (5 minutes).
|
|
62
|
+
# Rationale: 5 minutes is long enough to indicate potential issues (API outage,
|
|
63
|
+
# misconfigured limits) but short enough to be actionable. Matches typical
|
|
64
|
+
# monitoring alert thresholds for request latency.
|
|
65
|
+
DEFAULT_QUEUE_WAIT_WARNING_THRESHOLD = 300
|
|
66
|
+
|
|
67
|
+
# Default wait time when rate limit is exhausted but reset_at is unknown (60 seconds).
|
|
68
|
+
# Rationale: sec-api.io rate limit windows are typically 60 seconds. When the API
|
|
69
|
+
# doesn't send X-RateLimit-Reset header, this provides a reasonable fallback that
|
|
70
|
+
# aligns with expected window duration without excessive waiting.
|
|
71
|
+
DEFAULT_QUEUE_WAIT_SECONDS = 60
|
|
72
|
+
|
|
73
|
+
# Creates a new RateLimiter middleware instance.
|
|
74
|
+
#
|
|
75
|
+
# @param app [#call] The next middleware in the stack
|
|
76
|
+
# @param options [Hash] Configuration options
|
|
77
|
+
# @option options [RateLimitTracker] :state_store The tracker to update with rate limit info
|
|
78
|
+
# @option options [Float] :threshold (0.1) Throttle when percentage remaining drops below
|
|
79
|
+
# this value (0.0-1.0). Default is 0.1 (10%).
|
|
80
|
+
# @option options [Proc, nil] :on_throttle Callback invoked when throttling occurs.
|
|
81
|
+
# Receives a hash with :remaining, :limit, :delay, :reset_at, and :request_id keys.
|
|
82
|
+
# @option options [Proc, nil] :on_queue Callback invoked when a request is queued
|
|
83
|
+
# due to exhausted rate limit (remaining = 0). Receives a hash with :queue_size,
|
|
84
|
+
# :wait_time, :reset_at, and :request_id keys.
|
|
85
|
+
# @option options [Integer] :queue_wait_warning_threshold (300) Seconds threshold
|
|
86
|
+
# for warning about excessive wait times. Default is 300 (5 minutes).
|
|
87
|
+
# @option options [Proc, nil] :on_excessive_wait Callback invoked when wait time
|
|
88
|
+
# exceeds queue_wait_warning_threshold. Receives a hash with :wait_time,
|
|
89
|
+
# :threshold, :reset_at, and :request_id keys.
|
|
90
|
+
# @option options [Proc, nil] :on_dequeue Callback invoked when a request exits
|
|
91
|
+
# the queue (after waiting). Receives a hash with :queue_size, :waited, and :request_id keys.
|
|
92
|
+
# @option options [Logger, nil] :logger Logger instance for structured rate limit logging.
|
|
93
|
+
# Set to nil (default) to disable logging.
|
|
94
|
+
# @option options [Symbol] :log_level (:info) Log level for rate limit events.
|
|
95
|
+
#
|
|
96
|
+
# @example With custom threshold, callbacks, and logging
|
|
97
|
+
# tracker = SecApi::RateLimitTracker.new
|
|
98
|
+
# middleware = SecApi::Middleware::RateLimiter.new(app,
|
|
99
|
+
# state_store: tracker,
|
|
100
|
+
# threshold: 0.2, # Throttle at 20% remaining
|
|
101
|
+
# on_throttle: ->(info) { puts "Throttling for #{info[:delay]}s" },
|
|
102
|
+
# on_queue: ->(info) { puts "Request queued, #{info[:queue_size]} waiting" },
|
|
103
|
+
# on_dequeue: ->(info) { puts "Request dequeued after #{info[:waited]}s" },
|
|
104
|
+
# on_excessive_wait: ->(info) { puts "Warning: wait time #{info[:wait_time]}s" },
|
|
105
|
+
# logger: Rails.logger,
|
|
106
|
+
# log_level: :info
|
|
107
|
+
# )
|
|
108
|
+
#
|
|
109
|
+
def initialize(app, options = {})
|
|
110
|
+
super(app)
|
|
111
|
+
@state_store = options[:state_store]
|
|
112
|
+
@threshold = options.fetch(:threshold, DEFAULT_THRESHOLD)
|
|
113
|
+
@on_throttle = options[:on_throttle]
|
|
114
|
+
@on_queue = options[:on_queue]
|
|
115
|
+
@on_dequeue = options[:on_dequeue]
|
|
116
|
+
@on_excessive_wait = options[:on_excessive_wait]
|
|
117
|
+
@queue_wait_warning_threshold = options.fetch(
|
|
118
|
+
:queue_wait_warning_threshold,
|
|
119
|
+
DEFAULT_QUEUE_WAIT_WARNING_THRESHOLD
|
|
120
|
+
)
|
|
121
|
+
@logger = options[:logger]
|
|
122
|
+
@log_level = options.fetch(:log_level, :info)
|
|
123
|
+
# Thread-safety design: Mutex + ConditionVariable pattern for efficient blocking.
|
|
124
|
+
# Why not just sleep? Sleep wastes CPU cycles polling. ConditionVariable allows
|
|
125
|
+
# threads to truly wait (zero CPU) until signaled, crucial for high-concurrency
|
|
126
|
+
# workloads (Sidekiq, Puma) where many threads may be rate-limited simultaneously.
|
|
127
|
+
# Why not atomic counters? We need to coordinate multiple operations (check state,
|
|
128
|
+
# increment queue, wait) atomically, which requires a mutex.
|
|
129
|
+
@mutex = Mutex.new
|
|
130
|
+
@condition = ConditionVariable.new
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# Returns the current count of queued (waiting) requests.
|
|
134
|
+
#
|
|
135
|
+
# Delegates to the state store if available, otherwise returns 0.
|
|
136
|
+
#
|
|
137
|
+
# @return [Integer] Number of requests currently waiting for rate limit reset
|
|
138
|
+
def queued_count
|
|
139
|
+
@state_store&.queued_count || 0
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# Processes the request with rate limit queueing, throttling, and header extraction.
|
|
143
|
+
#
|
|
144
|
+
# Before sending the request:
|
|
145
|
+
# 1. Generates a unique request_id (UUID) for tracing across callbacks
|
|
146
|
+
# 2. If rate limit is exhausted (remaining = 0), queues the request until reset
|
|
147
|
+
# 3. Otherwise, checks if below threshold and throttles if needed
|
|
148
|
+
#
|
|
149
|
+
# After the response, extracts rate limit headers to update state.
|
|
150
|
+
#
|
|
151
|
+
# @param env [Faraday::Env] The request/response environment
|
|
152
|
+
# @return [Faraday::Response] The response
|
|
153
|
+
#
|
|
154
|
+
def call(env)
|
|
155
|
+
# Generate unique request_id for tracing across all callbacks
|
|
156
|
+
request_id = env[:request_id] ||= SecureRandom.uuid
|
|
157
|
+
|
|
158
|
+
wait_if_exhausted(request_id)
|
|
159
|
+
throttle_if_needed(request_id)
|
|
160
|
+
|
|
161
|
+
@app.call(env).on_complete do |response_env|
|
|
162
|
+
extract_rate_limit_headers(response_env)
|
|
163
|
+
signal_waiting_threads
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
private
|
|
168
|
+
|
|
169
|
+
# Blocks the request if the rate limit is exhausted (remaining = 0).
|
|
170
|
+
#
|
|
171
|
+
# When exhausted, increments the queued count, invokes the on_queue callback,
|
|
172
|
+
# and waits using ConditionVariable until signaled or timeout. Uses a while
|
|
173
|
+
# loop to re-check state after wakeup in case another thread took the slot.
|
|
174
|
+
#
|
|
175
|
+
# Thread-safety: Uses mutex and ConditionVariable for efficient blocking.
|
|
176
|
+
# Sequential release: After reset, threads are signaled one at a time.
|
|
177
|
+
# Exception safety: Uses ensure block to guarantee queued_count is decremented.
|
|
178
|
+
#
|
|
179
|
+
# @param request_id [String] Unique identifier for tracing this request
|
|
180
|
+
# @return [void]
|
|
181
|
+
#
|
|
182
|
+
def wait_if_exhausted(request_id)
|
|
183
|
+
return unless @state_store
|
|
184
|
+
|
|
185
|
+
@mutex.synchronize do
|
|
186
|
+
state = @state_store.current_state
|
|
187
|
+
return unless state
|
|
188
|
+
return unless exhausted?(state)
|
|
189
|
+
|
|
190
|
+
# Calculate wait time, using default if reset_at is unknown
|
|
191
|
+
wait_time = calculate_delay_with_default(state)
|
|
192
|
+
return unless wait_time.positive?
|
|
193
|
+
|
|
194
|
+
queued_at = Time.now
|
|
195
|
+
@state_store.increment_queued
|
|
196
|
+
begin
|
|
197
|
+
invoke_queue_callback(state, wait_time, request_id)
|
|
198
|
+
warn_if_excessive_wait(wait_time, state.reset_at, request_id)
|
|
199
|
+
|
|
200
|
+
# Wait using ConditionVariable with timeout.
|
|
201
|
+
# Re-check state after wakeup in while loop - another thread may have taken
|
|
202
|
+
# the available capacity before this thread resumes (spurious wakeup handling).
|
|
203
|
+
# This is the standard pattern for condition variable usage (Mesa semantics).
|
|
204
|
+
while should_wait?
|
|
205
|
+
remaining_wait = calculate_remaining_wait_with_default
|
|
206
|
+
break unless remaining_wait.positive?
|
|
207
|
+
@condition.wait(@mutex, remaining_wait)
|
|
208
|
+
end
|
|
209
|
+
ensure
|
|
210
|
+
@state_store.decrement_queued
|
|
211
|
+
invoke_dequeue_callback(Time.now - queued_at, request_id)
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
# Determines if the current thread should continue waiting.
|
|
217
|
+
#
|
|
218
|
+
# Checks if rate limit is still exhausted. Does not check reset_passed
|
|
219
|
+
# because we may be using default wait time when reset_at is nil.
|
|
220
|
+
# Called after ConditionVariable wakeup to re-verify state.
|
|
221
|
+
#
|
|
222
|
+
# @return [Boolean] true if thread should continue waiting
|
|
223
|
+
#
|
|
224
|
+
def should_wait?
|
|
225
|
+
state = @state_store.current_state
|
|
226
|
+
return false unless state
|
|
227
|
+
exhausted?(state)
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
# Calculates remaining wait time until reset, with default fallback.
|
|
231
|
+
#
|
|
232
|
+
# When reset_at is nil (API didn't send X-RateLimit-Reset header),
|
|
233
|
+
# returns 0 to allow the wait loop to exit and retry.
|
|
234
|
+
#
|
|
235
|
+
# @return [Float] Seconds remaining until reset, or 0 if unknown/passed
|
|
236
|
+
#
|
|
237
|
+
def calculate_remaining_wait_with_default
|
|
238
|
+
state = @state_store.current_state
|
|
239
|
+
return 0 unless state&.reset_at
|
|
240
|
+
delay = state.reset_at - Time.now
|
|
241
|
+
delay.positive? ? delay : 0
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
# Checks if the rate limit is exhausted (remaining = 0).
|
|
245
|
+
#
|
|
246
|
+
# @param state [RateLimitState] The current rate limit state
|
|
247
|
+
# @return [Boolean] true if remaining is exactly 0
|
|
248
|
+
#
|
|
249
|
+
def exhausted?(state)
|
|
250
|
+
state.remaining&.zero?
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
# Invokes the on_queue callback if configured and logs the event.
|
|
254
|
+
#
|
|
255
|
+
# @param state [RateLimitState] The current rate limit state
|
|
256
|
+
# @param wait_time [Float] Seconds the request will wait
|
|
257
|
+
# @param request_id [String] Unique identifier for tracing this request
|
|
258
|
+
# @return [void]
|
|
259
|
+
#
|
|
260
|
+
def invoke_queue_callback(state, wait_time, request_id)
|
|
261
|
+
log_queue(state, wait_time, request_id)
|
|
262
|
+
|
|
263
|
+
return unless @on_queue
|
|
264
|
+
|
|
265
|
+
@on_queue.call(
|
|
266
|
+
queue_size: queued_count,
|
|
267
|
+
wait_time: wait_time,
|
|
268
|
+
reset_at: state.reset_at,
|
|
269
|
+
request_id: request_id
|
|
270
|
+
)
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
# Logs a queue event with structured data.
|
|
274
|
+
#
|
|
275
|
+
# @param state [RateLimitState] The current rate limit state
|
|
276
|
+
# @param wait_time [Float] Seconds the request will wait
|
|
277
|
+
# @param request_id [String] Unique identifier for tracing this request
|
|
278
|
+
# @return [void]
|
|
279
|
+
#
|
|
280
|
+
def log_queue(state, wait_time, request_id)
|
|
281
|
+
return unless @logger
|
|
282
|
+
|
|
283
|
+
log_event(
|
|
284
|
+
event: "secapi.rate_limit.queue",
|
|
285
|
+
request_id: request_id,
|
|
286
|
+
queue_size: queued_count,
|
|
287
|
+
wait_time: wait_time.round(2),
|
|
288
|
+
reset_at: state.reset_at&.iso8601
|
|
289
|
+
)
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
# Invokes the on_dequeue callback if configured.
|
|
293
|
+
#
|
|
294
|
+
# Called when a request exits the queue after waiting.
|
|
295
|
+
#
|
|
296
|
+
# @param waited [Float] Actual seconds the request waited
|
|
297
|
+
# @param request_id [String] Unique identifier for tracing this request
|
|
298
|
+
# @return [void]
|
|
299
|
+
#
|
|
300
|
+
def invoke_dequeue_callback(waited, request_id)
|
|
301
|
+
return unless @on_dequeue
|
|
302
|
+
|
|
303
|
+
@on_dequeue.call(
|
|
304
|
+
queue_size: queued_count,
|
|
305
|
+
waited: waited,
|
|
306
|
+
request_id: request_id
|
|
307
|
+
)
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
# Calculates delay until reset, using default when reset_at is unknown.
|
|
311
|
+
#
|
|
312
|
+
# When the API returns remaining=0 but no X-RateLimit-Reset header,
|
|
313
|
+
# uses DEFAULT_QUEUE_WAIT_SECONDS (60s) as a fallback.
|
|
314
|
+
#
|
|
315
|
+
# @param state [RateLimitState] The current rate limit state
|
|
316
|
+
# @return [Float] Seconds to wait (always positive when exhausted)
|
|
317
|
+
#
|
|
318
|
+
def calculate_delay_with_default(state)
|
|
319
|
+
if state.reset_at.nil?
|
|
320
|
+
# No reset time known - use default wait
|
|
321
|
+
DEFAULT_QUEUE_WAIT_SECONDS
|
|
322
|
+
else
|
|
323
|
+
delay = state.reset_at - Time.now
|
|
324
|
+
delay.positive? ? delay : 0
|
|
325
|
+
end
|
|
326
|
+
end
|
|
327
|
+
|
|
328
|
+
# Warns when wait time exceeds the configured threshold.
|
|
329
|
+
#
|
|
330
|
+
# Invokes the on_excessive_wait callback when the wait time is greater
|
|
331
|
+
# than queue_wait_warning_threshold (default 300 seconds / 5 minutes).
|
|
332
|
+
# The request continues waiting after the warning.
|
|
333
|
+
#
|
|
334
|
+
# @param wait_time [Float] Seconds the request will wait
|
|
335
|
+
# @param reset_at [Time] When the rate limit resets
|
|
336
|
+
# @param request_id [String] Unique identifier for tracing this request
|
|
337
|
+
# @return [void]
|
|
338
|
+
#
|
|
339
|
+
def warn_if_excessive_wait(wait_time, reset_at, request_id)
|
|
340
|
+
return unless wait_time > @queue_wait_warning_threshold
|
|
341
|
+
return unless @on_excessive_wait
|
|
342
|
+
|
|
343
|
+
@on_excessive_wait.call(
|
|
344
|
+
wait_time: wait_time,
|
|
345
|
+
threshold: @queue_wait_warning_threshold,
|
|
346
|
+
reset_at: reset_at,
|
|
347
|
+
request_id: request_id
|
|
348
|
+
)
|
|
349
|
+
end
|
|
350
|
+
|
|
351
|
+
# Signals waiting threads that the rate limit may have reset.
|
|
352
|
+
#
|
|
353
|
+
# Called after each response to wake up one waiting thread.
|
|
354
|
+
# Uses ConditionVariable for efficient thread coordination.
|
|
355
|
+
#
|
|
356
|
+
# @return [void]
|
|
357
|
+
#
|
|
358
|
+
def signal_waiting_threads
|
|
359
|
+
@mutex.synchronize do
|
|
360
|
+
@condition.signal
|
|
361
|
+
end
|
|
362
|
+
end
|
|
363
|
+
|
|
364
|
+
# Checks rate limit state and sleeps if below threshold.
|
|
365
|
+
#
|
|
366
|
+
# Only throttles when:
|
|
367
|
+
# - State store exists
|
|
368
|
+
# - Current state exists (at least one prior response received)
|
|
369
|
+
# - Rate limit is NOT exhausted (remaining > 0) - exhausted case handled by wait_if_exhausted
|
|
370
|
+
# - Reset time has not passed (state is still valid)
|
|
371
|
+
# - Percentage remaining is below threshold
|
|
372
|
+
# - Delay is positive (reset is in the future)
|
|
373
|
+
#
|
|
374
|
+
# @param request_id [String] Unique identifier for tracing this request
|
|
375
|
+
# @return [void]
|
|
376
|
+
#
|
|
377
|
+
def throttle_if_needed(request_id)
|
|
378
|
+
return unless @state_store
|
|
379
|
+
|
|
380
|
+
state = @state_store.current_state
|
|
381
|
+
return unless state
|
|
382
|
+
return if exhausted?(state) # Exhausted case handled separately by queueing
|
|
383
|
+
return if reset_passed?(state)
|
|
384
|
+
return unless should_throttle?(state)
|
|
385
|
+
|
|
386
|
+
delay = calculate_delay(state)
|
|
387
|
+
return unless delay.positive?
|
|
388
|
+
|
|
389
|
+
invoke_throttle_callback(state, delay, request_id)
|
|
390
|
+
sleep(delay)
|
|
391
|
+
end
|
|
392
|
+
|
|
393
|
+
# Invokes the on_throttle callback if configured and logs the event.
|
|
394
|
+
#
|
|
395
|
+
# @param state [RateLimitState] The current rate limit state
|
|
396
|
+
# @param delay [Float] Seconds the request will be delayed
|
|
397
|
+
# @param request_id [String] Unique identifier for tracing this request
|
|
398
|
+
# @return [void]
|
|
399
|
+
#
|
|
400
|
+
def invoke_throttle_callback(state, delay, request_id)
|
|
401
|
+
log_throttle(state, delay, request_id)
|
|
402
|
+
|
|
403
|
+
return unless @on_throttle
|
|
404
|
+
|
|
405
|
+
@on_throttle.call(
|
|
406
|
+
remaining: state.remaining,
|
|
407
|
+
limit: state.limit,
|
|
408
|
+
delay: delay,
|
|
409
|
+
reset_at: state.reset_at,
|
|
410
|
+
request_id: request_id
|
|
411
|
+
)
|
|
412
|
+
end
|
|
413
|
+
|
|
414
|
+
# Logs a throttle event with structured data.
|
|
415
|
+
#
|
|
416
|
+
# @param state [RateLimitState] The current rate limit state
|
|
417
|
+
# @param delay [Float] Seconds the request will be delayed
|
|
418
|
+
# @param request_id [String] Unique identifier for tracing this request
|
|
419
|
+
# @return [void]
|
|
420
|
+
#
|
|
421
|
+
def log_throttle(state, delay, request_id)
|
|
422
|
+
return unless @logger
|
|
423
|
+
|
|
424
|
+
log_event(
|
|
425
|
+
event: "secapi.rate_limit.throttle",
|
|
426
|
+
request_id: request_id,
|
|
427
|
+
remaining: state.remaining,
|
|
428
|
+
limit: state.limit,
|
|
429
|
+
delay: delay.round(2),
|
|
430
|
+
reset_at: state.reset_at&.iso8601
|
|
431
|
+
)
|
|
432
|
+
end
|
|
433
|
+
|
|
434
|
+
# Determines if throttling should be applied based on remaining percentage.
|
|
435
|
+
#
|
|
436
|
+
# @param state [RateLimitState] The current rate limit state
|
|
437
|
+
# @return [Boolean] true if percentage remaining is below threshold
|
|
438
|
+
#
|
|
439
|
+
def should_throttle?(state)
|
|
440
|
+
pct = state.percentage_remaining
|
|
441
|
+
return false if pct.nil?
|
|
442
|
+
|
|
443
|
+
# percentage_remaining returns 0.0-100.0, threshold is 0.0-1.0
|
|
444
|
+
# Convert threshold to percentage: 0.1 * 100 = 10%
|
|
445
|
+
pct < (@threshold * 100)
|
|
446
|
+
end
|
|
447
|
+
|
|
448
|
+
# Checks if the rate limit window has already reset.
|
|
449
|
+
#
|
|
450
|
+
# @param state [RateLimitState] The current rate limit state
|
|
451
|
+
# @return [Boolean] true if reset_at is nil or in the past
|
|
452
|
+
#
|
|
453
|
+
def reset_passed?(state)
|
|
454
|
+
return true if state.reset_at.nil?
|
|
455
|
+
Time.now >= state.reset_at
|
|
456
|
+
end
|
|
457
|
+
|
|
458
|
+
# Calculates the delay in seconds until the rate limit resets.
|
|
459
|
+
#
|
|
460
|
+
# @param state [RateLimitState] The current rate limit state
|
|
461
|
+
# @return [Float] Seconds to wait (0 or positive)
|
|
462
|
+
#
|
|
463
|
+
def calculate_delay(state)
|
|
464
|
+
return 0 if state.reset_at.nil?
|
|
465
|
+
|
|
466
|
+
delay = state.reset_at - Time.now
|
|
467
|
+
delay.positive? ? delay : 0
|
|
468
|
+
end
|
|
469
|
+
|
|
470
|
+
# Extracts rate limit headers and updates the state store.
|
|
471
|
+
#
|
|
472
|
+
# Only updates state if at least one rate limit header is present.
|
|
473
|
+
# Missing headers result in nil values for those fields.
|
|
474
|
+
#
|
|
475
|
+
# @param env [Faraday::Env] The response environment
|
|
476
|
+
#
|
|
477
|
+
def extract_rate_limit_headers(env)
|
|
478
|
+
return unless @state_store
|
|
479
|
+
|
|
480
|
+
headers = env[:response_headers]
|
|
481
|
+
return unless headers
|
|
482
|
+
|
|
483
|
+
limit = parse_integer(headers[LIMIT_HEADER])
|
|
484
|
+
remaining = parse_integer(headers[REMAINING_HEADER])
|
|
485
|
+
reset_at = parse_timestamp(headers[RESET_HEADER])
|
|
486
|
+
|
|
487
|
+
# Only update if we got at least one header
|
|
488
|
+
return if limit.nil? && remaining.nil? && reset_at.nil?
|
|
489
|
+
|
|
490
|
+
@state_store.update(
|
|
491
|
+
limit: limit,
|
|
492
|
+
remaining: remaining,
|
|
493
|
+
reset_at: reset_at
|
|
494
|
+
)
|
|
495
|
+
end
|
|
496
|
+
|
|
497
|
+
# Parses a header value as an integer.
|
|
498
|
+
#
|
|
499
|
+
# @param value [String, nil] The header value
|
|
500
|
+
# @return [Integer, nil] The parsed integer or nil
|
|
501
|
+
#
|
|
502
|
+
def parse_integer(value)
|
|
503
|
+
return nil if value.nil? || value.to_s.empty?
|
|
504
|
+
Integer(value)
|
|
505
|
+
rescue ArgumentError, TypeError
|
|
506
|
+
nil
|
|
507
|
+
end
|
|
508
|
+
|
|
509
|
+
# Parses a Unix timestamp header value as a Time object.
|
|
510
|
+
#
|
|
511
|
+
# @param value [String, nil] The Unix timestamp header value
|
|
512
|
+
# @return [Time, nil] The parsed Time object or nil
|
|
513
|
+
#
|
|
514
|
+
def parse_timestamp(value)
|
|
515
|
+
return nil if value.nil? || value.to_s.empty?
|
|
516
|
+
Time.at(Integer(value))
|
|
517
|
+
rescue ArgumentError, TypeError
|
|
518
|
+
nil
|
|
519
|
+
end
|
|
520
|
+
|
|
521
|
+
# Logs a structured event using the configured logger and log level.
|
|
522
|
+
#
|
|
523
|
+
# Outputs events as JSON for compatibility with monitoring tools
|
|
524
|
+
# like ELK, Splunk, and Datadog.
|
|
525
|
+
#
|
|
526
|
+
# @param data [Hash] Event data to log
|
|
527
|
+
# @return [void]
|
|
528
|
+
#
|
|
529
|
+
def log_event(data)
|
|
530
|
+
return unless @logger
|
|
531
|
+
|
|
532
|
+
begin
|
|
533
|
+
@logger.send(@log_level) { data.to_json }
|
|
534
|
+
rescue
|
|
535
|
+
# Don't let logging errors break the request
|
|
536
|
+
# Silently ignore - logging is best-effort
|
|
537
|
+
end
|
|
538
|
+
end
|
|
539
|
+
end
|
|
540
|
+
end
|
|
541
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
require "dry/struct"
|
|
2
|
+
require "sec_api/objects/document_format_file"
|
|
3
|
+
|
|
4
|
+
module SecApi
|
|
5
|
+
module Objects
|
|
6
|
+
# Represents a data file (XBRL, XML, etc.) within an SEC filing.
|
|
7
|
+
#
|
|
8
|
+
# DataFile objects inherit from {DocumentFormatFile} and represent
|
|
9
|
+
# structured data files such as XBRL instance documents, XML schemas,
|
|
10
|
+
# and other machine-readable attachments. All instances are immutable.
|
|
11
|
+
#
|
|
12
|
+
# DataFile inherits all attributes from {DocumentFormatFile}:
|
|
13
|
+
# - `sequence` - File sequence number
|
|
14
|
+
# - `description` - File description (optional)
|
|
15
|
+
# - `type` - MIME type or file type
|
|
16
|
+
# - `url` - Direct URL to download the file
|
|
17
|
+
# - `size` - File size in bytes
|
|
18
|
+
#
|
|
19
|
+
# @example Accessing data files from a filing
|
|
20
|
+
# filing = client.query.ticker("AAPL").form_type("10-K").search.first
|
|
21
|
+
# filing.data_files.each do |file|
|
|
22
|
+
# puts "#{file.description}: #{file.url}"
|
|
23
|
+
# end
|
|
24
|
+
#
|
|
25
|
+
# @example Finding XBRL instance documents
|
|
26
|
+
# xbrl_files = filing.data_files.select { |f| f.type.include?("xml") }
|
|
27
|
+
#
|
|
28
|
+
# @see SecApi::Objects::Filing#data_files
|
|
29
|
+
# @see SecApi::Objects::DocumentFormatFile Parent class with all attributes
|
|
30
|
+
#
|
|
31
|
+
class DataFile < DocumentFormatFile
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
require "dry/struct"
|
|
2
|
+
|
|
3
|
+
module SecApi
|
|
4
|
+
module Objects
|
|
5
|
+
# Represents a document file within an SEC filing.
|
|
6
|
+
#
|
|
7
|
+
# DocumentFormatFile objects contain metadata about individual documents
|
|
8
|
+
# within a filing, such as the main filing document, exhibits, and
|
|
9
|
+
# attachments. All instances are immutable (frozen).
|
|
10
|
+
#
|
|
11
|
+
# @example Accessing document files from a filing
|
|
12
|
+
# filing = client.query.ticker("AAPL").form_type("10-K").search.first
|
|
13
|
+
# filing.documents.each do |doc|
|
|
14
|
+
# puts "#{doc.sequence}: #{doc.description} (#{doc.type})"
|
|
15
|
+
# puts "URL: #{doc.url}, Size: #{doc.size} bytes"
|
|
16
|
+
# end
|
|
17
|
+
#
|
|
18
|
+
# @see SecApi::Objects::Filing#documents
|
|
19
|
+
# @see SecApi::Objects::DataFile
|
|
20
|
+
#
|
|
21
|
+
class DocumentFormatFile < Dry::Struct
|
|
22
|
+
transform_keys { |key| key.to_s.underscore }
|
|
23
|
+
transform_keys(&:to_sym)
|
|
24
|
+
|
|
25
|
+
attribute :sequence, Types::String
|
|
26
|
+
attribute? :description, Types::String
|
|
27
|
+
attribute :type, Types::String
|
|
28
|
+
attribute :url, Types::String
|
|
29
|
+
attribute :size, Types::Coercible::Integer
|
|
30
|
+
|
|
31
|
+
# Creates a DocumentFormatFile from API response data.
|
|
32
|
+
#
|
|
33
|
+
# Normalizes camelCase keys from the API to snake_case format.
|
|
34
|
+
#
|
|
35
|
+
# @param data [Hash] API response hash with document data
|
|
36
|
+
# @return [DocumentFormatFile] Immutable document file object
|
|
37
|
+
#
|
|
38
|
+
def self.from_api(data)
|
|
39
|
+
data[:url] = data.delete(:documentUrl) if data.key?(:documentUrl)
|
|
40
|
+
|
|
41
|
+
new(data)
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|