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,411 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SecApi
4
+ # Provides centralized metric recording for SEC API operations.
5
+ #
6
+ # This module standardizes metric names and tags across all integrations.
7
+ # Use directly with your metrics backend, or configure via `metrics_backend`
8
+ # for automatic metrics collection.
9
+ #
10
+ # All metric methods are safe to call - they will not raise exceptions even
11
+ # if the backend is nil, misconfigured, or raises errors. This ensures that
12
+ # metrics never break production operations.
13
+ #
14
+ # @example Direct usage with StatsD
15
+ # statsd = Datadog::Statsd.new('localhost', 8125)
16
+ #
17
+ # config.on_response = ->(request_id:, status:, duration_ms:, url:, method:) {
18
+ # SecApi::MetricsCollector.record_response(statsd,
19
+ # status: status,
20
+ # duration_ms: duration_ms,
21
+ # method: method
22
+ # )
23
+ # }
24
+ #
25
+ # @example Automatic metrics with metrics_backend
26
+ # config = SecApi::Config.new(
27
+ # api_key: "...",
28
+ # metrics_backend: Datadog::Statsd.new('localhost', 8125)
29
+ # )
30
+ # # Metrics automatically collected for all operations
31
+ #
32
+ # @example New Relic custom events
33
+ # config.on_response = ->(request_id:, status:, duration_ms:, url:, method:) {
34
+ # NewRelic::Agent.record_custom_event("SecApiRequest", {
35
+ # request_id: request_id,
36
+ # status: status,
37
+ # duration_ms: duration_ms,
38
+ # method: method.to_s.upcase
39
+ # })
40
+ # }
41
+ #
42
+ # @example Datadog APM integration
43
+ # config.on_request = ->(request_id:, method:, url:, headers:) {
44
+ # Datadog::Tracing.trace('sec_api.request') do |span|
45
+ # span.set_tag('request_id', request_id)
46
+ # span.set_tag('http.method', method.to_s.upcase)
47
+ # span.set_tag('http.url', url)
48
+ # end
49
+ # }
50
+ #
51
+ # @example OpenTelemetry spans
52
+ # tracer = OpenTelemetry.tracer_provider.tracer('sec_api')
53
+ #
54
+ # config.on_request = ->(request_id:, method:, url:, headers:) {
55
+ # tracer.in_span('sec_api.request', attributes: {
56
+ # 'sec_api.request_id' => request_id,
57
+ # 'http.method' => method.to_s.upcase,
58
+ # 'http.url' => url
59
+ # }) { }
60
+ # }
61
+ #
62
+ # @example Prometheus push gateway
63
+ # prometheus = Prometheus::Client.registry
64
+ # requests_total = prometheus.counter(:sec_api_requests_total, labels: [:method, :status])
65
+ # duration_histogram = prometheus.histogram(:sec_api_request_duration_seconds, labels: [:method])
66
+ #
67
+ # config.on_response = ->(request_id:, status:, duration_ms:, url:, method:) {
68
+ # requests_total.increment(labels: {method: method.to_s.upcase, status: status.to_s})
69
+ # duration_histogram.observe(duration_ms / 1000.0, labels: {method: method.to_s.upcase})
70
+ # }
71
+ #
72
+ module MetricsCollector
73
+ extend self
74
+
75
+ # Metric name for total requests made (counter).
76
+ # @return [String] metric name
77
+ REQUESTS_TOTAL = "sec_api.requests.total"
78
+
79
+ # Metric name for successful requests (counter, status < 400).
80
+ # @return [String] metric name
81
+ REQUESTS_SUCCESS = "sec_api.requests.success"
82
+
83
+ # Metric name for error requests (counter, status >= 400).
84
+ # @return [String] metric name
85
+ REQUESTS_ERROR = "sec_api.requests.error"
86
+
87
+ # Metric name for request duration (histogram, milliseconds).
88
+ # @return [String] metric name
89
+ REQUESTS_DURATION = "sec_api.requests.duration_ms"
90
+
91
+ # Metric name for retry attempts (counter).
92
+ # @return [String] metric name
93
+ RETRIES_TOTAL = "sec_api.retries.total"
94
+
95
+ # Metric name for exhausted retries (counter).
96
+ # @return [String] metric name
97
+ RETRIES_EXHAUSTED = "sec_api.retries.exhausted"
98
+
99
+ # Metric name for rate limit hits (counter, 429 responses).
100
+ # @return [String] metric name
101
+ RATE_LIMIT_HIT = "sec_api.rate_limit.hit"
102
+
103
+ # Metric name for proactive throttling events (counter).
104
+ # @return [String] metric name
105
+ RATE_LIMIT_THROTTLE = "sec_api.rate_limit.throttle"
106
+
107
+ # Metric name for streaming filings received (counter).
108
+ # @return [String] metric name
109
+ STREAM_FILINGS = "sec_api.stream.filings"
110
+
111
+ # Metric name for streaming delivery latency (histogram, milliseconds).
112
+ # @return [String] metric name
113
+ STREAM_LATENCY = "sec_api.stream.latency_ms"
114
+
115
+ # Metric name for stream reconnection events (counter).
116
+ # @return [String] metric name
117
+ STREAM_RECONNECTS = "sec_api.stream.reconnects"
118
+
119
+ # Metric name for filing journey stage duration (histogram, milliseconds).
120
+ # @return [String] metric name
121
+ JOURNEY_STAGE_DURATION = "sec_api.filing.journey.stage_ms"
122
+
123
+ # Metric name for total filing journey duration (histogram, milliseconds).
124
+ # @return [String] metric name
125
+ JOURNEY_TOTAL_DURATION = "sec_api.filing.journey.total_ms"
126
+
127
+ # Records a successful or failed response.
128
+ #
129
+ # Increments request counters and records duration histogram.
130
+ # Status codes < 400 are considered successful, >= 400 are errors.
131
+ #
132
+ # @param backend [Object] Metrics backend (StatsD, Datadog::Statsd, etc.)
133
+ # @param status [Integer] HTTP status code
134
+ # @param duration_ms [Integer] Request duration in milliseconds
135
+ # @param method [Symbol] HTTP method (:get, :post, etc.)
136
+ # @return [void]
137
+ #
138
+ # @example Record a successful response
139
+ # MetricsCollector.record_response(statsd, status: 200, duration_ms: 150, method: :get)
140
+ #
141
+ # @example Record an error response
142
+ # MetricsCollector.record_response(statsd, status: 429, duration_ms: 50, method: :get)
143
+ #
144
+ def record_response(backend, status:, duration_ms:, method:)
145
+ tags = {method: method.to_s.upcase, status: status.to_s}
146
+
147
+ increment(backend, REQUESTS_TOTAL, tags: tags)
148
+
149
+ if status < 400
150
+ increment(backend, REQUESTS_SUCCESS, tags: tags)
151
+ else
152
+ increment(backend, REQUESTS_ERROR, tags: tags)
153
+ end
154
+
155
+ histogram(backend, REQUESTS_DURATION, duration_ms, tags: tags)
156
+ end
157
+
158
+ # Records a retry attempt.
159
+ #
160
+ # @param backend [Object] Metrics backend
161
+ # @param attempt [Integer] Retry attempt number (1-indexed)
162
+ # @param error_class [String] Exception class name that triggered retry
163
+ # @return [void]
164
+ #
165
+ # @example Record a retry attempt
166
+ # MetricsCollector.record_retry(statsd, attempt: 1, error_class: "SecApi::NetworkError")
167
+ #
168
+ def record_retry(backend, attempt:, error_class:)
169
+ tags = {attempt: attempt.to_s, error_class: error_class}
170
+ increment(backend, RETRIES_TOTAL, tags: tags)
171
+ end
172
+
173
+ # Records a final error (all retries exhausted).
174
+ #
175
+ # @param backend [Object] Metrics backend
176
+ # @param error_class [String] Exception class name
177
+ # @param method [Symbol] HTTP method
178
+ # @return [void]
179
+ #
180
+ # @example Record a final error
181
+ # MetricsCollector.record_error(statsd, error_class: "SecApi::NetworkError", method: :get)
182
+ #
183
+ def record_error(backend, error_class:, method:)
184
+ tags = {error_class: error_class, method: method.to_s.upcase}
185
+ increment(backend, RETRIES_EXHAUSTED, tags: tags)
186
+ end
187
+
188
+ # Records a rate limit (429) response.
189
+ #
190
+ # @param backend [Object] Metrics backend
191
+ # @param retry_after [Integer, nil] Seconds to wait before retry
192
+ # @return [void]
193
+ #
194
+ # @example Record a rate limit hit
195
+ # MetricsCollector.record_rate_limit(statsd, retry_after: 30)
196
+ #
197
+ def record_rate_limit(backend, retry_after: nil)
198
+ increment(backend, RATE_LIMIT_HIT)
199
+ gauge(backend, "sec_api.rate_limit.retry_after", retry_after) if retry_after
200
+ end
201
+
202
+ # Records proactive throttling.
203
+ #
204
+ # Called when the rate limiter proactively delays a request to avoid
205
+ # hitting the rate limit.
206
+ #
207
+ # @param backend [Object] Metrics backend
208
+ # @param remaining [Integer] Requests remaining before limit
209
+ # @param delay [Float] Seconds the request was delayed
210
+ # @return [void]
211
+ #
212
+ # @example Record proactive throttling
213
+ # MetricsCollector.record_throttle(statsd, remaining: 5, delay: 1.5)
214
+ #
215
+ def record_throttle(backend, remaining:, delay:)
216
+ increment(backend, RATE_LIMIT_THROTTLE)
217
+ gauge(backend, "sec_api.rate_limit.remaining", remaining)
218
+ histogram(backend, "sec_api.rate_limit.delay_ms", (delay * 1000).round)
219
+ end
220
+
221
+ # Records a streaming filing received.
222
+ #
223
+ # @param backend [Object] Metrics backend
224
+ # @param latency_ms [Integer] Filing delivery latency in milliseconds
225
+ # @param form_type [String] Filing form type (10-K, 8-K, etc.)
226
+ # @return [void]
227
+ #
228
+ # @example Record a filing receipt
229
+ # MetricsCollector.record_filing(statsd, latency_ms: 500, form_type: "10-K")
230
+ #
231
+ def record_filing(backend, latency_ms:, form_type:)
232
+ tags = {form_type: form_type}
233
+ increment(backend, STREAM_FILINGS, tags: tags)
234
+ histogram(backend, STREAM_LATENCY, latency_ms, tags: tags)
235
+ end
236
+
237
+ # Records a stream reconnection.
238
+ #
239
+ # @param backend [Object] Metrics backend
240
+ # @param attempt_count [Integer] Number of reconnection attempts
241
+ # @param downtime_seconds [Float] Total downtime in seconds
242
+ # @return [void]
243
+ #
244
+ # @example Record a reconnection
245
+ # MetricsCollector.record_reconnect(statsd, attempt_count: 3, downtime_seconds: 15.5)
246
+ #
247
+ def record_reconnect(backend, attempt_count:, downtime_seconds:)
248
+ increment(backend, STREAM_RECONNECTS)
249
+ gauge(backend, "sec_api.stream.reconnect_attempts", attempt_count)
250
+ histogram(backend, "sec_api.stream.downtime_ms", (downtime_seconds * 1000).round)
251
+ end
252
+
253
+ # Records a filing journey stage completion.
254
+ #
255
+ # Use this to track duration of individual pipeline stages (detected,
256
+ # queried, extracted, processed). Combined with FilingJourney logging,
257
+ # this provides both detailed logs and aggregated metrics.
258
+ #
259
+ # @param backend [Object] Metrics backend
260
+ # @param stage [String] Journey stage (detected, queried, extracted, processed)
261
+ # @param duration_ms [Integer] Stage duration in milliseconds
262
+ # @param form_type [String, nil] Filing form type (10-K, 8-K, etc.)
263
+ # @return [void]
264
+ #
265
+ # @example Record a query stage
266
+ # MetricsCollector.record_journey_stage(statsd,
267
+ # stage: "queried",
268
+ # duration_ms: 150,
269
+ # form_type: "10-K"
270
+ # )
271
+ #
272
+ # @see FilingJourney
273
+ #
274
+ def record_journey_stage(backend, stage:, duration_ms:, form_type: nil)
275
+ tags = {stage: stage}
276
+ tags[:form_type] = form_type if form_type
277
+ histogram(backend, JOURNEY_STAGE_DURATION, duration_ms, tags: tags)
278
+ end
279
+
280
+ # Records total filing journey duration.
281
+ #
282
+ # Use this to track end-to-end pipeline latency from filing detection
283
+ # through processing completion. Useful for monitoring SLAs and
284
+ # identifying slow pipelines.
285
+ #
286
+ # @param backend [Object] Metrics backend
287
+ # @param total_ms [Integer] Total pipeline duration in milliseconds
288
+ # @param form_type [String, nil] Filing form type (10-K, 8-K, etc.)
289
+ # @param success [Boolean] Whether processing succeeded (default: true)
290
+ # @return [void]
291
+ #
292
+ # @example Record successful pipeline
293
+ # MetricsCollector.record_journey_total(statsd,
294
+ # total_ms: 5000,
295
+ # form_type: "10-K",
296
+ # success: true
297
+ # )
298
+ #
299
+ # @example Record failed pipeline
300
+ # MetricsCollector.record_journey_total(statsd,
301
+ # total_ms: 500,
302
+ # form_type: "10-K",
303
+ # success: false
304
+ # )
305
+ #
306
+ # @see FilingJourney
307
+ #
308
+ def record_journey_total(backend, total_ms:, form_type: nil, success: true)
309
+ tags = {success: success.to_s}
310
+ tags[:form_type] = form_type if form_type
311
+ histogram(backend, JOURNEY_TOTAL_DURATION, total_ms, tags: tags)
312
+ end
313
+
314
+ private
315
+
316
+ # Increment a counter metric.
317
+ #
318
+ # Supports both statsd-ruby (no tags) and dogstatsd-ruby (with tags) interfaces.
319
+ # Falls back to no-tag call if backend doesn't support tags.
320
+ #
321
+ # @param backend [Object] Metrics backend
322
+ # @param metric [String] Metric name
323
+ # @param tags [Hash] Tags to include (optional)
324
+ # @return [void]
325
+ # @api private
326
+ def increment(backend, metric, tags: {})
327
+ return unless backend
328
+ return unless backend.respond_to?(:increment)
329
+
330
+ if tags.any? && supports_tags?(backend, :increment)
331
+ backend.increment(metric, tags: format_tags(tags))
332
+ else
333
+ backend.increment(metric)
334
+ end
335
+ rescue
336
+ # Don't let metrics errors break operations
337
+ end
338
+
339
+ # Record a histogram/timing metric.
340
+ #
341
+ # Falls back to timing method if histogram is not available.
342
+ #
343
+ # @param backend [Object] Metrics backend
344
+ # @param metric [String] Metric name
345
+ # @param value [Numeric] Value to record
346
+ # @param tags [Hash] Tags to include (optional)
347
+ # @return [void]
348
+ # @api private
349
+ def histogram(backend, metric, value, tags: {})
350
+ return unless backend
351
+
352
+ if backend.respond_to?(:histogram)
353
+ if tags.any? && supports_tags?(backend, :histogram)
354
+ backend.histogram(metric, value, tags: format_tags(tags))
355
+ else
356
+ backend.histogram(metric, value)
357
+ end
358
+ elsif backend.respond_to?(:timing)
359
+ backend.timing(metric, value)
360
+ end
361
+ rescue
362
+ # Don't let metrics errors break operations
363
+ end
364
+
365
+ # Record a gauge metric.
366
+ #
367
+ # @param backend [Object] Metrics backend
368
+ # @param metric [String] Metric name
369
+ # @param value [Numeric] Value to record
370
+ # @param tags [Hash] Tags to include (optional)
371
+ # @return [void]
372
+ # @api private
373
+ def gauge(backend, metric, value, tags: {})
374
+ return unless backend
375
+ return unless backend.respond_to?(:gauge)
376
+
377
+ if tags.any? && supports_tags?(backend, :gauge)
378
+ backend.gauge(metric, value, tags: format_tags(tags))
379
+ else
380
+ backend.gauge(metric, value)
381
+ end
382
+ rescue
383
+ # Don't let metrics errors break operations
384
+ end
385
+
386
+ # Check if backend method supports tags (arity > minimum required args).
387
+ #
388
+ # @param backend [Object] Metrics backend
389
+ # @param method_name [Symbol] Method name to check
390
+ # @return [Boolean] True if tags are supported
391
+ # @api private
392
+ def supports_tags?(backend, method_name)
393
+ method = backend.method(method_name)
394
+ # arity of -1 or -2 means variable args (accepts keyword args)
395
+ # arity of 1 means only the metric name (no tags)
396
+ # arity of 2 means metric + value (no tags)
397
+ method.arity < 0 || method.arity > 2
398
+ rescue
399
+ false
400
+ end
401
+
402
+ # Format tags hash as array of "key:value" strings for StatsD/Datadog.
403
+ #
404
+ # @param tags [Hash] Tags hash
405
+ # @return [Array<String>] Formatted tags
406
+ # @api private
407
+ def format_tags(tags)
408
+ tags.map { |k, v| "#{k}:#{v}" }
409
+ end
410
+ end
411
+ end
@@ -0,0 +1,250 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "faraday"
4
+
5
+ module SecApi
6
+ # Faraday middleware for SEC API operations.
7
+ #
8
+ # These middleware classes handle common concerns like error handling,
9
+ # rate limiting, and instrumentation. They are automatically configured
10
+ # in the Client's Faraday connection.
11
+ #
12
+ # @see SecApi::Middleware::ErrorHandler HTTP error to exception conversion
13
+ # @see SecApi::Middleware::RateLimiter Rate limit tracking and throttling
14
+ # @see SecApi::Middleware::Instrumentation Request/response instrumentation
15
+ #
16
+ module Middleware
17
+ # Faraday middleware that converts HTTP status codes and Faraday exceptions
18
+ # into typed SecApi exceptions.
19
+ #
20
+ # This middleware maps:
21
+ # - HTTP 400 → ValidationError (permanent)
22
+ # - HTTP 401 → AuthenticationError (permanent)
23
+ # - HTTP 403 → AuthenticationError (permanent)
24
+ # - HTTP 404 → NotFoundError (permanent)
25
+ # - HTTP 422 → ValidationError (permanent)
26
+ # - HTTP 429 → RateLimitError (transient)
27
+ # - HTTP 5xx → ServerError (transient)
28
+ # - Faraday::TimeoutError → NetworkError (transient)
29
+ # - Faraday::ConnectionFailed → NetworkError (transient)
30
+ # - Faraday::SSLError → NetworkError (transient)
31
+ #
32
+ # Position in middleware stack: After retry/rate limiter, before adapter
33
+ #
34
+ # @raise [ValidationError] when API returns 400 (Bad Request) or 422 (Unprocessable Entity)
35
+ # @raise [AuthenticationError] when API returns 401 (Unauthorized) or 403 (Forbidden)
36
+ # @raise [NotFoundError] when API returns 404 (Not Found)
37
+ # @raise [RateLimitError] when API returns 429 (Too Many Requests)
38
+ # @raise [ServerError] when API returns 5xx (Server Error)
39
+ # @raise [NetworkError] when network issues occur (timeout, connection failure, SSL error)
40
+ class ErrorHandler < Faraday::Middleware
41
+ # Initializes the error handler middleware.
42
+ #
43
+ # @param app [Faraday::Middleware] The next middleware in the stack
44
+ # @param options [Hash] Configuration options
45
+ # @option options [SecApi::Config] :config The config object containing on_error callback
46
+ def initialize(app, options = {})
47
+ super(app)
48
+ @config = options[:config]
49
+ end
50
+
51
+ # Processes the request and converts HTTP errors to typed exceptions.
52
+ #
53
+ # @param env [Faraday::Env] The request/response environment
54
+ # @return [Faraday::Response] The response (if no error)
55
+ # @raise [ValidationError] when API returns 400 or 422
56
+ # @raise [AuthenticationError] when API returns 401 or 403
57
+ # @raise [NotFoundError] when API returns 404
58
+ # @raise [RateLimitError] when API returns 429
59
+ # @raise [ServerError] when API returns 5xx
60
+ # @raise [NetworkError] when network issues occur
61
+ #
62
+ def call(env)
63
+ response = @app.call(env)
64
+ handle_response(response.env)
65
+ response
66
+ rescue Faraday::RetriableResponse => e
67
+ # Faraday retry raises this to signal a retry - we need to re-raise it
68
+ # so retry middleware can catch it
69
+ raise e
70
+ rescue Faraday::TimeoutError => e
71
+ # Don't invoke on_error here - TransientErrors will be retried.
72
+ # on_error is invoked by Instrumentation middleware after all retries exhausted.
73
+ raise NetworkError.new(
74
+ "Request timeout. " \
75
+ "Check network connectivity or increase request_timeout in configuration. " \
76
+ "Original error: #{e.message}.",
77
+ request_id: env[:request_id]
78
+ )
79
+ rescue Faraday::ConnectionFailed => e
80
+ # Don't invoke on_error here - TransientErrors will be retried.
81
+ # on_error is invoked by Instrumentation middleware after all retries exhausted.
82
+ raise NetworkError.new(
83
+ "Connection failed: #{e.message}. " \
84
+ "Verify network connectivity and sec-api.io availability. " \
85
+ "This is a temporary issue that will be retried automatically.",
86
+ request_id: env[:request_id]
87
+ )
88
+ rescue Faraday::SSLError => e
89
+ # Don't invoke on_error here - TransientErrors will be retried.
90
+ # on_error is invoked by Instrumentation middleware after all retries exhausted.
91
+ raise NetworkError.new(
92
+ "SSL/TLS error: #{e.message}. " \
93
+ "This may indicate certificate validation issues or secure connection problems. " \
94
+ "Verify your system's SSL certificates are up to date.",
95
+ request_id: env[:request_id]
96
+ )
97
+ end
98
+
99
+ private
100
+
101
+ def handle_response(env)
102
+ # Only handle error responses - skip success responses
103
+ return if env[:status] >= 200 && env[:status] < 300
104
+
105
+ error = build_error_for_status(env)
106
+ return unless error
107
+
108
+ # NOTE: on_error callback is NOT invoked here.
109
+ # All on_error invocations happen in Instrumentation middleware after the exception
110
+ # escapes all middleware (including retry). This ensures on_error is called exactly once,
111
+ # only when the request ultimately fails (after all retries exhausted for TransientError,
112
+ # or immediately for PermanentError).
113
+ raise error
114
+ end
115
+
116
+ # Builds the appropriate error for the HTTP status code.
117
+ #
118
+ # Error Taxonomy Decision (Architecture ADR-2):
119
+ # - TransientError (retryable): Network issues, server errors, rate limits - worth retrying
120
+ # because the underlying issue may resolve. Supports NFR5: 95%+ automatic recovery.
121
+ # - PermanentError (fail-fast): Client errors like auth, validation, not found - no point
122
+ # retrying because the same request will always fail. Fail immediately to save resources.
123
+ #
124
+ # @param env [Faraday::Env] The response environment
125
+ # @return [SecApi::Error, nil] The appropriate error, or nil for unhandled status
126
+ def build_error_for_status(env)
127
+ request_id = env[:request_id]
128
+
129
+ case env[:status]
130
+ # 400/422: PermanentError - Client sent invalid data. Retrying won't help.
131
+ when 400
132
+ ValidationError.new(
133
+ "Bad request (400): The request was malformed or contains invalid parameters. " \
134
+ "Check your query parameters, ticker symbols, or search criteria.",
135
+ request_id: request_id
136
+ )
137
+ # 401/403: PermanentError - Auth issues need human intervention (fix API key or subscription).
138
+ # Both map to AuthenticationError because the resolution is the same: fix credentials.
139
+ when 401
140
+ AuthenticationError.new(
141
+ "API authentication failed (401 Unauthorized). " \
142
+ "Verify your API key in config/secapi.yml or SECAPI_API_KEY environment variable. " \
143
+ "Get your API key from https://sec-api.io.",
144
+ request_id: request_id
145
+ )
146
+ when 403
147
+ AuthenticationError.new(
148
+ "Access forbidden (403): Your API key does not have permission for this resource. " \
149
+ "Verify your subscription plan at https://sec-api.io or contact support.",
150
+ request_id: request_id
151
+ )
152
+ # 404: PermanentError - Resource doesn't exist. Retrying won't create it.
153
+ when 404
154
+ NotFoundError.new(
155
+ "Resource not found (404): #{env[:url]&.path || "unknown"}. " \
156
+ "Check ticker symbol, CIK, or filing identifier.",
157
+ request_id: request_id
158
+ )
159
+ when 422
160
+ ValidationError.new(
161
+ "Unprocessable entity (422): The request was valid but could not be processed. " \
162
+ "This may indicate invalid query logic or unsupported parameter combinations.",
163
+ request_id: request_id
164
+ )
165
+ # 429: TransientError - Rate limit will reset. Worth waiting and retrying automatically.
166
+ # Supports FR5.4: "automatically resume when capacity returns"
167
+ when 429
168
+ retry_after = parse_retry_after(env[:response_headers])
169
+ reset_at = parse_reset_timestamp(env[:response_headers])
170
+
171
+ RateLimitError.new(
172
+ build_rate_limit_message(retry_after, reset_at),
173
+ retry_after: retry_after,
174
+ reset_at: reset_at,
175
+ request_id: request_id
176
+ )
177
+ # 5xx: TransientError - Server issues are typically temporary. Retry with backoff.
178
+ # Supports NFR5: 95%+ automatic recovery from transient failures.
179
+ when 500..504
180
+ ServerError.new(
181
+ "sec-api.io server error (#{env[:status]}). " \
182
+ "automatic retry attempts exhausted. This may indicate a prolonged outage.",
183
+ request_id: request_id
184
+ )
185
+ end
186
+ end
187
+
188
+ # Parses Retry-After header value (integer seconds or HTTP-date format).
189
+ #
190
+ # @param headers [Hash, nil] Response headers
191
+ # @return [Integer, nil] Seconds to wait, or nil if header not present/invalid
192
+ def parse_retry_after(headers)
193
+ return nil unless headers
194
+
195
+ value = headers["Retry-After"] || headers["retry-after"]
196
+ return nil unless value
197
+
198
+ # Try integer format first (e.g., "60")
199
+ Integer(value, 10)
200
+ rescue ArgumentError
201
+ # Try HTTP-date format (e.g., "Wed, 07 Jan 2026 12:00:00 GMT")
202
+ begin
203
+ http_date = Time.httpdate(value)
204
+ delay = (http_date - Time.now).to_i
205
+ [delay, 0].max
206
+ rescue ArgumentError
207
+ nil
208
+ end
209
+ end
210
+
211
+ # Parses X-RateLimit-Reset header to a Time object.
212
+ #
213
+ # @param headers [Hash, nil] Response headers
214
+ # @return [Time, nil] Reset timestamp, or nil if header not present/invalid
215
+ def parse_reset_timestamp(headers)
216
+ return nil unless headers
217
+
218
+ value = headers["X-RateLimit-Reset"] || headers["x-ratelimit-reset"]
219
+ return nil unless value
220
+
221
+ Time.at(Integer(value, 10))
222
+ rescue ArgumentError, TypeError
223
+ nil
224
+ end
225
+
226
+ # Builds an actionable error message for rate limit errors.
227
+ #
228
+ # @param retry_after [Integer, nil] Seconds to wait
229
+ # @param reset_at [Time, nil] Reset timestamp
230
+ # @return [String] Formatted error message
231
+ def build_rate_limit_message(retry_after, reset_at)
232
+ parts = ["Rate limit exceeded (429 Too Many Requests)."]
233
+
234
+ if retry_after
235
+ parts << "Retry after #{retry_after} seconds."
236
+ elsif reset_at
237
+ parts << "Rate limit resets at #{reset_at.utc.strftime("%Y-%m-%d %H:%M:%S UTC")}."
238
+ end
239
+
240
+ parts << "Automatic retry attempts exhausted. Consider implementing backoff or reducing request rate."
241
+ parts.join(" ")
242
+ end
243
+
244
+ # NOTE: on_error callback is NOT invoked here.
245
+ # All on_error invocations happen in Instrumentation middleware (first in stack)
246
+ # after exceptions escape all middleware (including retry). This ensures on_error
247
+ # is called exactly once, only when the request ultimately fails.
248
+ end
249
+ end
250
+ end