sec_api 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. checksums.yaml +7 -0
  2. data/.devcontainer/Dockerfile +54 -0
  3. data/.devcontainer/README.md +178 -0
  4. data/.devcontainer/devcontainer.json +46 -0
  5. data/.devcontainer/docker-compose.yml +28 -0
  6. data/.devcontainer/post-create.sh +51 -0
  7. data/.devcontainer/post-start.sh +44 -0
  8. data/.rspec +3 -0
  9. data/.standard.yml +3 -0
  10. data/CHANGELOG.md +5 -0
  11. data/CLAUDE.md +0 -0
  12. data/LICENSE.txt +21 -0
  13. data/MIGRATION.md +274 -0
  14. data/README.md +370 -0
  15. data/Rakefile +10 -0
  16. data/config/secapi.yml.example +57 -0
  17. data/docs/development-guide.md +291 -0
  18. data/docs/enumerator_pattern_design.md +483 -0
  19. data/docs/examples/README.md +58 -0
  20. data/docs/examples/backfill_filings.rb +419 -0
  21. data/docs/examples/instrumentation.rb +583 -0
  22. data/docs/examples/query_builder.rb +308 -0
  23. data/docs/examples/streaming_notifications.rb +491 -0
  24. data/docs/index.md +244 -0
  25. data/docs/migration-guide-v1.md +1091 -0
  26. data/docs/pre-review-checklist.md +145 -0
  27. data/docs/project-overview.md +90 -0
  28. data/docs/project-scan-report.json +60 -0
  29. data/docs/source-tree-analysis.md +190 -0
  30. data/lib/sec_api/callback_helper.rb +49 -0
  31. data/lib/sec_api/client.rb +606 -0
  32. data/lib/sec_api/collections/filings.rb +267 -0
  33. data/lib/sec_api/collections/fulltext_results.rb +86 -0
  34. data/lib/sec_api/config.rb +590 -0
  35. data/lib/sec_api/deep_freezable.rb +42 -0
  36. data/lib/sec_api/errors/authentication_error.rb +24 -0
  37. data/lib/sec_api/errors/configuration_error.rb +5 -0
  38. data/lib/sec_api/errors/error.rb +75 -0
  39. data/lib/sec_api/errors/network_error.rb +26 -0
  40. data/lib/sec_api/errors/not_found_error.rb +23 -0
  41. data/lib/sec_api/errors/pagination_error.rb +28 -0
  42. data/lib/sec_api/errors/permanent_error.rb +29 -0
  43. data/lib/sec_api/errors/rate_limit_error.rb +57 -0
  44. data/lib/sec_api/errors/reconnection_error.rb +34 -0
  45. data/lib/sec_api/errors/server_error.rb +25 -0
  46. data/lib/sec_api/errors/transient_error.rb +28 -0
  47. data/lib/sec_api/errors/validation_error.rb +23 -0
  48. data/lib/sec_api/extractor.rb +122 -0
  49. data/lib/sec_api/filing_journey.rb +477 -0
  50. data/lib/sec_api/mapping.rb +125 -0
  51. data/lib/sec_api/metrics_collector.rb +411 -0
  52. data/lib/sec_api/middleware/error_handler.rb +250 -0
  53. data/lib/sec_api/middleware/instrumentation.rb +186 -0
  54. data/lib/sec_api/middleware/rate_limiter.rb +541 -0
  55. data/lib/sec_api/objects/data_file.rb +34 -0
  56. data/lib/sec_api/objects/document_format_file.rb +45 -0
  57. data/lib/sec_api/objects/entity.rb +92 -0
  58. data/lib/sec_api/objects/extracted_data.rb +118 -0
  59. data/lib/sec_api/objects/fact.rb +147 -0
  60. data/lib/sec_api/objects/filing.rb +197 -0
  61. data/lib/sec_api/objects/fulltext_result.rb +66 -0
  62. data/lib/sec_api/objects/period.rb +96 -0
  63. data/lib/sec_api/objects/stream_filing.rb +194 -0
  64. data/lib/sec_api/objects/xbrl_data.rb +356 -0
  65. data/lib/sec_api/query.rb +423 -0
  66. data/lib/sec_api/rate_limit_state.rb +130 -0
  67. data/lib/sec_api/rate_limit_tracker.rb +154 -0
  68. data/lib/sec_api/stream.rb +841 -0
  69. data/lib/sec_api/structured_logger.rb +199 -0
  70. data/lib/sec_api/types.rb +32 -0
  71. data/lib/sec_api/version.rb +42 -0
  72. data/lib/sec_api/xbrl.rb +220 -0
  73. data/lib/sec_api.rb +137 -0
  74. data/sig/sec_api.rbs +4 -0
  75. metadata +217 -0
@@ -0,0 +1,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