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,841 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "faye/websocket"
4
+ require "eventmachine"
5
+ require "json"
6
+
7
+ module SecApi
8
+ # WebSocket streaming proxy for real-time SEC filing notifications.
9
+ #
10
+ # Connection Management Design (Architecture ADR-7):
11
+ # - Auto-reconnect: Network issues shouldn't require user intervention. Exponential
12
+ # backoff with jitter prevents thundering herd on server recovery.
13
+ # - Callback isolation: User callback exceptions mustn't crash the stream. We catch,
14
+ # log, and continue - the stream is more valuable than any single callback.
15
+ # - Best-effort delivery: Filings during disconnection are lost. Users must backfill
16
+ # via Query API if guaranteed delivery is required. This tradeoff keeps the stream
17
+ # simple and avoids complex replay/dedup logic.
18
+ #
19
+ # Connects to sec-api.io's Stream API via WebSocket and delivers
20
+ # filing notifications as they're published to the SEC EDGAR system.
21
+ #
22
+ # @example Subscribe to real-time filings
23
+ # client = SecApi::Client.new
24
+ # client.stream.subscribe do |filing|
25
+ # puts "New filing: #{filing.ticker} - #{filing.form_type}"
26
+ # end
27
+ #
28
+ # @example Close the streaming connection
29
+ # stream = client.stream
30
+ # stream.subscribe { |f| process(f) }
31
+ # # Later...
32
+ # stream.close
33
+ #
34
+ # @note The subscribe method blocks while receiving events.
35
+ # For non-blocking operation, run in a separate thread.
36
+ #
37
+ # @note **Security consideration:** The API key is passed as a URL query
38
+ # parameter (per sec-api.io Stream API specification). Unlike the REST API
39
+ # which uses the Authorization header, WebSocket URLs may be logged by
40
+ # proxies, load balancers, or web server access logs. Ensure your
41
+ # infrastructure does not log full WebSocket URLs in production.
42
+ #
43
+ # @note **Ping/Pong:** The sec-api.io server sends ping frames every 25 seconds
44
+ # and expects a pong response within 5 seconds. This is handled automatically
45
+ # by faye-websocket - no application code is required.
46
+ #
47
+ # @note **Sequential Processing:** Callbacks are invoked synchronously in the
48
+ # order filings are received. Each callback must complete before the next
49
+ # filing is processed. This guarantees ordering but means slow callbacks
50
+ # delay subsequent filings. For high-throughput scenarios, delegate work
51
+ # to background jobs.
52
+ #
53
+ # @note **Auto-Reconnect:** When the WebSocket connection is lost (network
54
+ # issues, server restart), the stream automatically attempts to reconnect
55
+ # using exponential backoff. After 10 failed attempts (by default), a
56
+ # {ReconnectionError} is raised. Configure via {Config#stream_max_reconnect_attempts},
57
+ # {Config#stream_initial_reconnect_delay}, {Config#stream_max_reconnect_delay},
58
+ # and {Config#stream_backoff_multiplier}.
59
+ #
60
+ # @note **Best-Effort Delivery:** Filings published during a disconnection
61
+ # window are **not** automatically replayed after reconnection. This is a
62
+ # "best-effort" delivery model. If you require guaranteed delivery, track
63
+ # the last received filing timestamp and use the Query API to backfill
64
+ # any gaps after reconnection. See the backfill example below.
65
+ #
66
+ # @note **No Ordering Guarantees During Reconnection:** While connected,
67
+ # filings arrive in order. However, during a reconnection gap, filings
68
+ # may be published to EDGAR that your application never sees. After
69
+ # backfilling, the combined set may not be in strict chronological order.
70
+ # Sort by filed_at if ordering is critical.
71
+ #
72
+ # @example Tracking last received filing for backfill detection
73
+ # last_filed_at = nil
74
+ #
75
+ # client.stream.subscribe do |filing|
76
+ # last_filed_at = filing.filed_at
77
+ # process_filing(filing)
78
+ # end
79
+ #
80
+ # @example Backfilling missed filings after reconnection
81
+ # disconnect_time = nil
82
+ # reconnect_time = nil
83
+ #
84
+ # config = SecApi::Config.new(
85
+ # api_key: "...",
86
+ # on_reconnect: ->(info) {
87
+ # reconnect_time = Time.now
88
+ # # info[:downtime_seconds] tells you how long you were disconnected
89
+ # Rails.logger.info("Reconnected after #{info[:downtime_seconds]}s downtime")
90
+ # }
91
+ # )
92
+ #
93
+ # client = SecApi::Client.new(config: config)
94
+ #
95
+ # # After reconnection, backfill via Query API:
96
+ # # missed_filings = client.query.filings(
97
+ # # filed_from: disconnect_time,
98
+ # # filed_to: reconnect_time,
99
+ # # tickers: ["AAPL"] # same filters as stream
100
+ # # )
101
+ #
102
+ class Stream
103
+ # WebSocket close code for normal closure (client or server initiated clean close).
104
+ # @return [Integer] WebSocket close code 1000
105
+ CLOSE_NORMAL = 1000
106
+
107
+ # WebSocket close code when endpoint is going away (server shutdown/restart).
108
+ # @return [Integer] WebSocket close code 1001
109
+ CLOSE_GOING_AWAY = 1001
110
+
111
+ # WebSocket close code for abnormal closure (connection lost unexpectedly).
112
+ # Triggers automatic reconnection when {Config#stream_max_reconnect_attempts} > 0.
113
+ # @return [Integer] WebSocket close code 1006
114
+ CLOSE_ABNORMAL = 1006
115
+
116
+ # WebSocket close code for policy violation (authentication failure).
117
+ # Raised as {SecApi::AuthenticationError} - does not trigger reconnection.
118
+ # @return [Integer] WebSocket close code 1008
119
+ CLOSE_POLICY_VIOLATION = 1008
120
+
121
+ # @return [SecApi::Client] The parent client instance
122
+ attr_reader :client
123
+
124
+ # Creates a new Stream proxy.
125
+ #
126
+ # @param client [SecApi::Client] The parent client for config access
127
+ #
128
+ def initialize(client)
129
+ @client = client
130
+ @ws = nil
131
+ @running = false
132
+ @callback = nil
133
+ @tickers = nil
134
+ @form_types = nil
135
+ @mutex = Mutex.new
136
+ # Reconnection state (Story 6.4)
137
+ @reconnect_attempts = 0
138
+ @should_reconnect = true
139
+ @reconnecting = false
140
+ @disconnect_time = nil
141
+ end
142
+
143
+ # Subscribe to real-time filing notifications with optional filtering.
144
+ #
145
+ # Establishes a WebSocket connection to sec-api.io's Stream API and
146
+ # invokes the provided block for each filing received. This method
147
+ # blocks while the connection is open.
148
+ #
149
+ # Filtering is performed client-side (sec-api.io streams all filings).
150
+ # When both tickers and form_types are specified, AND logic is applied.
151
+ #
152
+ # @param tickers [Array<String>, String, nil] Filter by ticker symbols (case-insensitive).
153
+ # Accepts array or single string.
154
+ # @param form_types [Array<String>, String, nil] Filter by form types (case-insensitive).
155
+ # Amendments are matched (e.g., "10-K" filter matches "10-K/A")
156
+ # @yield [SecApi::Objects::StreamFiling] Block called for each matching filing
157
+ # @return [void]
158
+ # @raise [ArgumentError] when no block is provided
159
+ # @raise [SecApi::NetworkError] on connection failure
160
+ # @raise [SecApi::AuthenticationError] on authentication failure (invalid API key)
161
+ #
162
+ # @example Basic subscription (all filings)
163
+ # client.stream.subscribe do |filing|
164
+ # puts "#{filing.ticker}: #{filing.form_type} filed at #{filing.filed_at}"
165
+ # end
166
+ #
167
+ # @example Filter by tickers
168
+ # client.stream.subscribe(tickers: ["AAPL", "TSLA"]) do |filing|
169
+ # puts "#{filing.ticker}: #{filing.form_type}"
170
+ # end
171
+ #
172
+ # @example Filter by form types
173
+ # client.stream.subscribe(form_types: ["10-K", "8-K"]) do |filing|
174
+ # process_material_event(filing)
175
+ # end
176
+ #
177
+ # @example Combined filters (AND logic)
178
+ # client.stream.subscribe(tickers: ["AAPL"], form_types: ["10-K", "10-Q"]) do |filing|
179
+ # analyze_apple_financials(filing)
180
+ # end
181
+ #
182
+ # @example Non-blocking subscription in separate thread
183
+ # Thread.new { client.stream.subscribe { |f| queue.push(f) } }
184
+ #
185
+ # @example Sidekiq job enqueueing (AC: #5)
186
+ # client.stream.subscribe(tickers: ["AAPL"]) do |filing|
187
+ # # Enqueue job and return quickly - don't block the reactor
188
+ # ProcessFilingJob.perform_async(filing.accession_no, filing.ticker)
189
+ # end
190
+ #
191
+ # @example ActiveJob integration (AC: #5)
192
+ # client.stream.subscribe do |filing|
193
+ # ProcessFilingJob.perform_later(
194
+ # accession_no: filing.accession_no,
195
+ # form_type: filing.form_type
196
+ # )
197
+ # end
198
+ #
199
+ # @example Thread pool processing (AC: #5)
200
+ # pool = Concurrent::ThreadPoolExecutor.new(max_threads: 10)
201
+ # client.stream.subscribe do |filing|
202
+ # pool.post { process_filing(filing) }
203
+ # end
204
+ #
205
+ # @note Callbacks execute synchronously in the EventMachine reactor thread.
206
+ # Long-running operations should be delegated to background jobs or thread
207
+ # pools to avoid blocking subsequent filing deliveries. Keep callbacks fast.
208
+ #
209
+ # @note Callback exceptions are caught and logged (if logger configured).
210
+ # Use {Config#on_callback_error} for custom error handling. The stream
211
+ # continues processing after callback exceptions.
212
+ #
213
+ def subscribe(tickers: nil, form_types: nil, &block)
214
+ raise ArgumentError, "Block required for subscribe" unless block_given?
215
+
216
+ @tickers = normalize_filter(tickers)
217
+ @form_types = normalize_filter(form_types)
218
+ @callback = block
219
+ connect
220
+ end
221
+
222
+ # Close the streaming connection.
223
+ #
224
+ # Gracefully closes the WebSocket connection and stops the EventMachine
225
+ # reactor. After closing, no further callbacks will be invoked.
226
+ #
227
+ # @return [void]
228
+ #
229
+ # @example
230
+ # stream.close
231
+ # stream.connected? # => false
232
+ #
233
+ def close
234
+ @mutex.synchronize do
235
+ # Prevent reconnection attempts after explicit close
236
+ @should_reconnect = false
237
+
238
+ return unless @ws
239
+
240
+ @ws.close(CLOSE_NORMAL, "Client requested close")
241
+ @ws = nil
242
+ @running = false
243
+ end
244
+ end
245
+
246
+ # Check if the stream is currently connected.
247
+ #
248
+ # @return [Boolean] true if WebSocket connection is open
249
+ #
250
+ def connected?
251
+ @mutex.synchronize do
252
+ @running && @ws && @ws.ready_state == Faye::WebSocket::API::OPEN
253
+ end
254
+ end
255
+
256
+ # Returns the current filter configuration.
257
+ #
258
+ # Useful for debugging and monitoring to inspect which filters are active.
259
+ #
260
+ # @return [Hash] Hash with :tickers and :form_types keys
261
+ #
262
+ # @example
263
+ # stream.subscribe(tickers: ["AAPL"]) { |f| }
264
+ # stream.filters # => { tickers: ["AAPL"], form_types: nil }
265
+ #
266
+ def filters
267
+ {
268
+ tickers: @tickers,
269
+ form_types: @form_types
270
+ }
271
+ end
272
+
273
+ private
274
+
275
+ # Establishes WebSocket connection and runs EventMachine reactor.
276
+ #
277
+ # @api private
278
+ def connect
279
+ url = build_url
280
+ @running = true
281
+
282
+ EM.run do
283
+ @ws = Faye::WebSocket::Client.new(url)
284
+ setup_handlers
285
+ end
286
+ rescue
287
+ @running = false
288
+ raise
289
+ end
290
+
291
+ # Builds the WebSocket URL with API key authentication.
292
+ #
293
+ # @return [String] WebSocket URL
294
+ # @api private
295
+ def build_url
296
+ "wss://stream.sec-api.io?apiKey=#{@client.config.api_key}"
297
+ end
298
+
299
+ # Sets up WebSocket event handlers.
300
+ #
301
+ # @api private
302
+ def setup_handlers
303
+ @ws.on :open do |_event|
304
+ @running = true
305
+
306
+ if @reconnecting
307
+ # Reconnection succeeded!
308
+ downtime = @disconnect_time ? Time.now - @disconnect_time : 0
309
+ log_reconnect_success(downtime)
310
+ invoke_on_reconnect_callback(@reconnect_attempts, downtime)
311
+
312
+ @reconnect_attempts = 0
313
+ @reconnecting = false
314
+ @disconnect_time = nil
315
+ end
316
+ end
317
+
318
+ @ws.on :message do |event|
319
+ handle_message(event.data)
320
+ end
321
+
322
+ @ws.on :close do |event|
323
+ handle_close(event.code, event.reason)
324
+ end
325
+
326
+ @ws.on :error do |event|
327
+ handle_error(event)
328
+ end
329
+ end
330
+
331
+ # Handles incoming WebSocket messages.
332
+ #
333
+ # Parses the JSON message containing filing data and invokes
334
+ # the callback for each filing in the array. Callbacks are
335
+ # suppressed after close() has been called.
336
+ #
337
+ # @param data [String] Raw JSON message from WebSocket
338
+ # @api private
339
+ def handle_message(data)
340
+ # Prevent callbacks after close (Task 5 requirement)
341
+ return unless @running
342
+
343
+ # Capture receive timestamp FIRST before any processing (Story 6.5, Task 5)
344
+ received_at = Time.now
345
+
346
+ filings = JSON.parse(data)
347
+ filings.each do |filing_data|
348
+ # Check again in loop in case close() called during iteration
349
+ break unless @running
350
+
351
+ # Pass received_at to constructor (Story 6.5, Task 5)
352
+ filing = Objects::StreamFiling.new(
353
+ transform_keys(filing_data).merge(received_at: received_at)
354
+ )
355
+
356
+ # Log filing receipt with latency (Story 6.5, Task 7)
357
+ log_filing_received(filing)
358
+
359
+ # Check latency threshold and log warning if exceeded (Story 6.5, Task 8)
360
+ check_latency_threshold(filing)
361
+
362
+ # Invoke instrumentation callback (Story 6.5, Task 6)
363
+ invoke_on_filing_callback(filing)
364
+
365
+ # Apply filters before callback (Story 6.2)
366
+ next unless matches_filters?(filing)
367
+
368
+ invoke_callback_safely(filing)
369
+ end
370
+ rescue JSON::ParserError => e
371
+ # Malformed JSON - log via client logger if available
372
+ log_parse_error("JSON parse error", e)
373
+ rescue Dry::Struct::Error => e
374
+ # Invalid filing data structure - log via client logger if available
375
+ log_parse_error("Filing data validation error", e)
376
+ end
377
+
378
+ # Transforms camelCase keys to snake_case symbols.
379
+ #
380
+ # @param hash [Hash] The hash with camelCase keys
381
+ # @return [Hash] Hash with snake_case symbol keys
382
+ # @api private
383
+ def transform_keys(hash)
384
+ hash.transform_keys do |key|
385
+ key.to_s.gsub(/([A-Z])/, '_\1').downcase.delete_prefix("_").to_sym
386
+ end
387
+ end
388
+
389
+ # Handles WebSocket close events.
390
+ #
391
+ # Triggers auto-reconnection for abnormal closures when should_reconnect is true.
392
+ # For non-reconnectable closures, raises appropriate errors.
393
+ #
394
+ # @param code [Integer] WebSocket close code
395
+ # @param reason [String] Close reason message
396
+ # @api private
397
+ def handle_close(code, reason)
398
+ was_running = false
399
+ @mutex.synchronize do
400
+ was_running = @running
401
+ @running = false
402
+ @ws = nil
403
+ end
404
+
405
+ # Only attempt reconnect if:
406
+ # 1. We were running (not a fresh connection failure)
407
+ # 2. User hasn't called close()
408
+ # 3. It's an abnormal close (not intentional)
409
+ if was_running && @should_reconnect && reconnectable_close?(code)
410
+ @disconnect_time = Time.now
411
+ schedule_reconnect
412
+ return # Don't stop EM or raise - let reconnection happen
413
+ end
414
+
415
+ EM.stop_event_loop if EM.reactor_running?
416
+
417
+ case code
418
+ when CLOSE_NORMAL, CLOSE_GOING_AWAY
419
+ # Normal closure - no error
420
+ when CLOSE_POLICY_VIOLATION
421
+ raise AuthenticationError.new(
422
+ "WebSocket authentication failed. Verify your API key is valid."
423
+ )
424
+ when CLOSE_ABNORMAL
425
+ raise NetworkError.new(
426
+ "WebSocket connection lost unexpectedly. Check network connectivity."
427
+ )
428
+ else
429
+ raise NetworkError.new(
430
+ "WebSocket closed with code #{code}: #{reason}"
431
+ )
432
+ end
433
+ end
434
+
435
+ # Handles WebSocket error events.
436
+ #
437
+ # @param event [Object] Error event from faye-websocket
438
+ # @api private
439
+ def handle_error(event)
440
+ @mutex.synchronize do
441
+ @running = false
442
+ @ws = nil
443
+ end
444
+
445
+ EM.stop_event_loop if EM.reactor_running?
446
+
447
+ error_message = event.respond_to?(:message) ? event.message : event.to_s
448
+ raise NetworkError.new(
449
+ "WebSocket error: #{error_message}"
450
+ )
451
+ end
452
+
453
+ # Logs message parsing errors via client logger if configured.
454
+ #
455
+ # @param context [String] Description of error context
456
+ # @param error [Exception] The exception that occurred
457
+ # @api private
458
+ def log_parse_error(context, error)
459
+ return unless @client.config.respond_to?(:logger) && @client.config.logger
460
+
461
+ log_data = {
462
+ event: "secapi.stream.parse_error",
463
+ context: context,
464
+ error_class: error.class.name,
465
+ error_message: error.message
466
+ }
467
+
468
+ begin
469
+ log_level = @client.config.respond_to?(:log_level) ? @client.config.log_level : :warn
470
+ @client.config.logger.send(log_level) { log_data.to_json }
471
+ rescue
472
+ # Don't let logging errors break message processing
473
+ end
474
+ end
475
+
476
+ # Invokes the user callback safely, catching and handling any exceptions.
477
+ #
478
+ # When a callback raises an exception, it is logged (if logger configured)
479
+ # and the on_callback_error callback is invoked (if configured). The stream
480
+ # continues processing subsequent filings.
481
+ #
482
+ # @param filing [SecApi::Objects::StreamFiling] The filing to pass to callback
483
+ # @api private
484
+ def invoke_callback_safely(filing)
485
+ @callback.call(filing)
486
+ rescue => e
487
+ log_callback_error(e, filing)
488
+ invoke_on_callback_error(e, filing)
489
+ # Continue processing - don't re-raise
490
+ end
491
+
492
+ # Logs callback exceptions via client logger if configured.
493
+ #
494
+ # @param error [Exception] The exception that occurred
495
+ # @param filing [SecApi::Objects::StreamFiling] The filing being processed
496
+ # @api private
497
+ def log_callback_error(error, filing)
498
+ return unless @client.config.logger
499
+
500
+ log_data = {
501
+ event: "secapi.stream.callback_error",
502
+ error_class: error.class.name,
503
+ error_message: error.message,
504
+ accession_no: filing.accession_no,
505
+ ticker: filing.ticker,
506
+ form_type: filing.form_type
507
+ }
508
+
509
+ begin
510
+ log_level = @client.config.log_level || :error
511
+ @client.config.logger.send(log_level) { log_data.to_json }
512
+ rescue
513
+ # Intentionally empty: logging failures must not break stream processing.
514
+ # The stream's resilience takes priority over error visibility.
515
+ end
516
+ end
517
+
518
+ # Invokes the on_callback_error callback if configured.
519
+ #
520
+ # @param error [Exception] The exception that occurred
521
+ # @param filing [SecApi::Objects::StreamFiling] The filing being processed
522
+ # @api private
523
+ def invoke_on_callback_error(error, filing)
524
+ return unless @client.config.on_callback_error
525
+
526
+ @client.config.on_callback_error.call(
527
+ error: error,
528
+ filing: filing,
529
+ accession_no: filing.accession_no,
530
+ ticker: filing.ticker
531
+ )
532
+ rescue
533
+ # Intentionally empty: error callback failures must not break stream processing.
534
+ # Prevents meta-errors (errors in error handlers) from crashing the stream.
535
+ end
536
+
537
+ # Invokes the on_reconnect callback if configured.
538
+ #
539
+ # Called after a successful reconnection to notify the application of the
540
+ # reconnection event. Exceptions are silently caught to prevent callback
541
+ # errors from disrupting the stream.
542
+ #
543
+ # @param attempt_count [Integer] Number of reconnection attempts before success
544
+ # @param downtime_seconds [Float] Total time disconnected in seconds
545
+ # @api private
546
+ def invoke_on_reconnect_callback(attempt_count, downtime_seconds)
547
+ return unless @client.config.on_reconnect
548
+
549
+ @client.config.on_reconnect.call(
550
+ attempt_count: attempt_count,
551
+ downtime_seconds: downtime_seconds
552
+ )
553
+ rescue
554
+ # Intentionally empty: reconnect callback failures must not break stream.
555
+ # The stream must remain operational regardless of callback behavior.
556
+ end
557
+
558
+ # Determines if a WebSocket close code indicates a reconnectable failure.
559
+ #
560
+ # Only reconnect for abnormal closure (network issues, server restart).
561
+ # Do NOT reconnect for: normal close, auth failure, policy violation.
562
+ #
563
+ # @param code [Integer] WebSocket close code
564
+ # @return [Boolean] true if reconnection should be attempted
565
+ # @api private
566
+ def reconnectable_close?(code)
567
+ code == CLOSE_ABNORMAL || code.between?(1011, 1015)
568
+ end
569
+
570
+ # Schedules a reconnection attempt after the calculated delay.
571
+ #
572
+ # @api private
573
+ def schedule_reconnect
574
+ delay = calculate_reconnect_delay
575
+ log_reconnect_attempt(delay)
576
+
577
+ EM.add_timer(delay) do
578
+ attempt_reconnect
579
+ end
580
+ end
581
+
582
+ # Attempts to reconnect to the WebSocket server.
583
+ #
584
+ # Increments the attempt counter and creates a new WebSocket connection.
585
+ # If max attempts exceeded, triggers reconnection failure handling.
586
+ #
587
+ # @api private
588
+ def attempt_reconnect
589
+ @reconnect_attempts += 1
590
+
591
+ if @reconnect_attempts > @client.config.stream_max_reconnect_attempts
592
+ handle_reconnection_failure
593
+ return
594
+ end
595
+
596
+ @reconnecting = true
597
+ @ws = Faye::WebSocket::Client.new(build_url)
598
+ setup_handlers
599
+ end
600
+
601
+ # Logs a reconnection attempt.
602
+ #
603
+ # @param delay [Float] Delay in seconds before this attempt
604
+ # @api private
605
+ def log_reconnect_attempt(delay)
606
+ return unless @client.config.logger
607
+
608
+ elapsed = @disconnect_time ? Time.now - @disconnect_time : 0
609
+ log_data = {
610
+ event: "secapi.stream.reconnect_attempt",
611
+ attempt: @reconnect_attempts,
612
+ max_attempts: @client.config.stream_max_reconnect_attempts,
613
+ delay: delay.round(2),
614
+ elapsed_seconds: elapsed.round(1)
615
+ }
616
+
617
+ begin
618
+ @client.config.logger.info { log_data.to_json }
619
+ rescue
620
+ # Don't let logging errors break reconnection
621
+ end
622
+ end
623
+
624
+ # Logs a successful reconnection.
625
+ #
626
+ # @param downtime [Float] Total downtime in seconds
627
+ # @api private
628
+ def log_reconnect_success(downtime)
629
+ return unless @client.config.logger
630
+
631
+ log_data = {
632
+ event: "secapi.stream.reconnect_success",
633
+ attempts: @reconnect_attempts,
634
+ downtime_seconds: downtime.round(1)
635
+ }
636
+
637
+ begin
638
+ @client.config.logger.info { log_data.to_json }
639
+ rescue
640
+ # Don't let logging errors break reconnection
641
+ end
642
+ end
643
+
644
+ # Logs filing receipt with latency information (Story 6.5, Task 7).
645
+ #
646
+ # @param filing [Objects::StreamFiling] The received filing
647
+ # @api private
648
+ def log_filing_received(filing)
649
+ return unless @client.config.logger
650
+
651
+ log_data = {
652
+ event: "secapi.stream.filing_received",
653
+ accession_no: filing.accession_no,
654
+ ticker: filing.ticker,
655
+ form_type: filing.form_type,
656
+ latency_ms: filing.latency_ms,
657
+ received_at: filing.received_at.iso8601(3)
658
+ }
659
+
660
+ begin
661
+ @client.config.logger.info { log_data.to_json }
662
+ rescue
663
+ # Don't let logging errors break stream processing
664
+ end
665
+ end
666
+
667
+ # Checks latency against threshold and logs warning if exceeded (Story 6.5, Task 8).
668
+ #
669
+ # @param filing [Objects::StreamFiling] The received filing
670
+ # @api private
671
+ def check_latency_threshold(filing)
672
+ return unless @client.config.logger
673
+ return unless filing.latency_seconds
674
+
675
+ threshold = @client.config.stream_latency_warning_threshold
676
+ return if filing.latency_seconds <= threshold
677
+
678
+ log_data = {
679
+ event: "secapi.stream.latency_warning",
680
+ accession_no: filing.accession_no,
681
+ ticker: filing.ticker,
682
+ form_type: filing.form_type,
683
+ latency_ms: filing.latency_ms,
684
+ threshold_seconds: threshold
685
+ }
686
+
687
+ begin
688
+ @client.config.logger.warn { log_data.to_json }
689
+ rescue
690
+ # Don't let logging errors break stream processing
691
+ end
692
+ end
693
+
694
+ # Invokes the on_filing instrumentation callback (Story 6.5, Task 6).
695
+ #
696
+ # @param filing [Objects::StreamFiling] The received filing
697
+ # @api private
698
+ def invoke_on_filing_callback(filing)
699
+ return unless @client.config.on_filing
700
+
701
+ begin
702
+ @client.config.on_filing.call(
703
+ filing: filing,
704
+ latency_ms: filing.latency_ms,
705
+ received_at: filing.received_at
706
+ )
707
+ rescue => e
708
+ # Don't let callback errors break stream processing
709
+ # Optionally log the error
710
+ log_on_filing_callback_error(filing, e)
711
+ end
712
+ end
713
+
714
+ # Logs errors from on_filing callback.
715
+ #
716
+ # @param filing [Objects::StreamFiling] The filing that caused the error
717
+ # @param error [Exception] The error that was raised
718
+ # @api private
719
+ def log_on_filing_callback_error(filing, error)
720
+ return unless @client.config.logger
721
+
722
+ log_data = {
723
+ event: "secapi.stream.on_filing_callback_error",
724
+ accession_no: filing.accession_no,
725
+ ticker: filing.ticker,
726
+ error_class: error.class.name,
727
+ error_message: error.message
728
+ }
729
+
730
+ begin
731
+ @client.config.logger.warn { log_data.to_json }
732
+ rescue
733
+ # Don't let logging errors break stream processing
734
+ end
735
+ end
736
+
737
+ # Handles reconnection failure after maximum attempts exceeded.
738
+ #
739
+ # @api private
740
+ def handle_reconnection_failure
741
+ @running = false
742
+ @reconnecting = false
743
+ EM.stop_event_loop if EM.reactor_running?
744
+
745
+ downtime = @disconnect_time ? Time.now - @disconnect_time : 0
746
+ raise ReconnectionError.new(
747
+ message: "WebSocket reconnection failed after #{@reconnect_attempts} attempts " \
748
+ "(#{downtime.round(1)}s downtime). Check network connectivity.",
749
+ attempts: @reconnect_attempts,
750
+ downtime_seconds: downtime
751
+ )
752
+ end
753
+
754
+ # Calculates delay before next reconnection attempt using exponential backoff.
755
+ #
756
+ # Formula: min(initial * (multiplier ^ attempt), max_delay) * jitter
757
+ #
758
+ # Why exponential backoff? Gives the server time to recover. If it's overloaded,
759
+ # we don't want to hammer it with immediate reconnects. Each failure suggests
760
+ # we should wait longer before trying again.
761
+ #
762
+ # Why jitter? When a server restarts, all clients reconnect simultaneously,
763
+ # potentially overwhelming the server again (thundering herd). Random jitter
764
+ # spreads out the reconnection attempts over time.
765
+ #
766
+ # @return [Float] Delay in seconds before next attempt
767
+ # @api private
768
+ def calculate_reconnect_delay
769
+ config = @client.config
770
+ base_delay = config.stream_initial_reconnect_delay *
771
+ (config.stream_backoff_multiplier**@reconnect_attempts)
772
+ capped_delay = [base_delay, config.stream_max_reconnect_delay].min
773
+
774
+ # Add jitter: random value between 0.9 and 1.1 of the delay
775
+ jitter_factor = 0.9 + rand * 0.2
776
+ capped_delay * jitter_factor
777
+ end
778
+
779
+ # Normalizes filter to uppercase strings for case-insensitive matching.
780
+ #
781
+ # Accepts an array of strings or a single string value. Single values
782
+ # are wrapped in an array for convenience. Duplicates are removed.
783
+ #
784
+ # @param filter [Array<String>, String, nil] Filter value(s)
785
+ # @return [Array<String>, nil] Normalized uppercase array, or nil if empty/nil
786
+ # @api private
787
+ def normalize_filter(filter)
788
+ return nil if filter.nil?
789
+
790
+ # Convert to array (handles single string input)
791
+ values = Array(filter)
792
+ return nil if values.empty?
793
+
794
+ values.map { |f| f.to_s.upcase }.uniq
795
+ end
796
+
797
+ # Checks if filing matches all configured filters (AND logic).
798
+ #
799
+ # @param filing [SecApi::Objects::StreamFiling] The filing to check
800
+ # @return [Boolean] true if filing passes all filters
801
+ # @api private
802
+ def matches_filters?(filing)
803
+ matches_ticker_filter?(filing) && matches_form_type_filter?(filing)
804
+ end
805
+
806
+ # Checks if filing matches the ticker filter.
807
+ #
808
+ # @param filing [SecApi::Objects::StreamFiling] The filing to check
809
+ # @return [Boolean] true if filing passes the ticker filter
810
+ # @api private
811
+ def matches_ticker_filter?(filing)
812
+ return true if @tickers.nil? # No filter = pass all
813
+
814
+ ticker = filing.ticker&.upcase
815
+ return true if ticker.nil? # No ticker in filing = pass through
816
+
817
+ @tickers.include?(ticker)
818
+ end
819
+
820
+ # Checks if filing matches the form_type filter.
821
+ #
822
+ # Amendments are handled specially: a filter for "10-K" will match both
823
+ # "10-K" and "10-K/A" filings. However, a filter for "10-K/A" only matches
824
+ # "10-K/A", not "10-K".
825
+ #
826
+ # @param filing [SecApi::Objects::StreamFiling] The filing to check
827
+ # @return [Boolean] true if filing passes the form_type filter
828
+ # @api private
829
+ def matches_form_type_filter?(filing)
830
+ return true if @form_types.nil? # No filter = pass all
831
+
832
+ form_type = filing.form_type&.upcase
833
+ return false if form_type.nil? # No form type = filter out
834
+
835
+ # Match exact or base form type (10-K/A matches 10-K filter)
836
+ @form_types.any? do |filter|
837
+ form_type == filter || form_type.start_with?("#{filter}/")
838
+ end
839
+ end
840
+ end
841
+ end