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,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
|