langfuse-ruby 0.2.0 → 0.2.1
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 +4 -4
- data/.rubocop.yml +1 -1
- data/CHANGELOG.md +44 -0
- data/CLAUDE.md +40 -14
- data/Gemfile.lock +1 -1
- data/Makefile +1 -4
- data/README.md +216 -99
- data/Rakefile +0 -6
- data/docs/FINAL_SUMMARY.md +12 -185
- data/docs/PUBLISH_GUIDE.md +36 -272
- data/docs/README.md +12 -22
- data/docs/RELEASE_CHECKLIST.md +20 -145
- data/docs/V4.md +159 -0
- data/examples/basic_tracing.rb +11 -10
- data/examples/simplified_usage.rb +7 -6
- data/examples/v4_otel_tracing.rb +70 -0
- data/lib/langfuse/client.rb +457 -247
- data/lib/langfuse/event.rb +1 -17
- data/lib/langfuse/generation.rb +35 -120
- data/lib/langfuse/null_objects.rb +4 -0
- data/lib/langfuse/otel_exporter.rb +101 -11
- data/lib/langfuse/partial_updates.rb +30 -0
- data/lib/langfuse/prompt.rb +9 -83
- data/lib/langfuse/prompt_cache.rb +65 -0
- data/lib/langfuse/span.rb +28 -153
- data/lib/langfuse/span_wrappers.rb +32 -0
- data/lib/langfuse/template_compiler.rb +56 -0
- data/lib/langfuse/trace.rb +26 -162
- data/lib/langfuse/utils.rb +28 -26
- data/lib/langfuse/version.rb +1 -1
- data/lib/langfuse.rb +49 -52
- data/scripts/release.sh +12 -12
- metadata +8 -2
data/lib/langfuse/client.rb
CHANGED
|
@@ -8,6 +8,7 @@ require 'base64'
|
|
|
8
8
|
require 'concurrent'
|
|
9
9
|
require 'logger'
|
|
10
10
|
require 'digest'
|
|
11
|
+
require 'time'
|
|
11
12
|
|
|
12
13
|
module Langfuse
|
|
13
14
|
class Client
|
|
@@ -17,6 +18,21 @@ module Langfuse
|
|
|
17
18
|
# Allowed format for the tracing environment field
|
|
18
19
|
ENVIRONMENT_PATTERN = /\A(?!langfuse)[a-z0-9\-_]{1,40}\z/
|
|
19
20
|
|
|
21
|
+
# How long shutdown waits for the flush thread to finish its current send
|
|
22
|
+
FLUSH_THREAD_JOIN_TIMEOUT = 5
|
|
23
|
+
|
|
24
|
+
# Dropped-event warnings are emitted on the first drop and then every N drops
|
|
25
|
+
DROPPED_EVENTS_WARN_INTERVAL = 100
|
|
26
|
+
|
|
27
|
+
# Supported ingestion transports: the legacy ingestion API and OTLP (Langfuse v4)
|
|
28
|
+
INGESTION_MODES = %i[legacy otel].freeze
|
|
29
|
+
|
|
30
|
+
# Backoff for transient request failures. The delay is jittered so that many
|
|
31
|
+
# clients hitting the same rate limit do not retry in lockstep, and capped so
|
|
32
|
+
# a server-sent Retry-After cannot stall a flush for minutes.
|
|
33
|
+
RETRY_BASE_DELAY_SECONDS = 0.5
|
|
34
|
+
MAX_RETRY_DELAY_SECONDS = 10
|
|
35
|
+
|
|
20
36
|
# Log device that resolves $stdout at write time so output redirection
|
|
21
37
|
# (e.g. in tests) keeps working after the logger was created.
|
|
22
38
|
class StdoutLogDevice
|
|
@@ -25,14 +41,21 @@ module Langfuse
|
|
|
25
41
|
end
|
|
26
42
|
|
|
27
43
|
def close; end
|
|
44
|
+
|
|
45
|
+
def flush
|
|
46
|
+
$stdout.flush
|
|
47
|
+
end
|
|
28
48
|
end
|
|
29
49
|
|
|
30
50
|
attr_reader :public_key, :secret_key, :host, :debug, :timeout, :retries, :flush_interval, :auto_flush,
|
|
31
|
-
:ingestion_mode, :environment, :sample_rate, :flush_at, :mask, :logger
|
|
51
|
+
:ingestion_mode, :environment, :sample_rate, :flush_at, :max_queue_size, :mask, :logger
|
|
32
52
|
|
|
33
|
-
|
|
53
|
+
# timeout/retries default to nil so that Langfuse.configure values are not
|
|
54
|
+
# shadowed by the method defaults; the fallbacks live in config_value.
|
|
55
|
+
def initialize(public_key: nil, secret_key: nil, host: nil, debug: false, timeout: nil, retries: nil,
|
|
34
56
|
flush_interval: nil, auto_flush: nil, ingestion_mode: nil, environment: nil,
|
|
35
|
-
sample_rate: nil, mask: nil, flush_at: nil,
|
|
57
|
+
sample_rate: nil, mask: nil, flush_at: nil, max_queue_size: nil, logger: nil,
|
|
58
|
+
shutdown_on_exit: nil, http_adapter: nil)
|
|
36
59
|
@public_key = config_value(public_key, 'LANGFUSE_PUBLIC_KEY', :public_key)
|
|
37
60
|
@secret_key = config_value(secret_key, 'LANGFUSE_SECRET_KEY', :secret_key)
|
|
38
61
|
@host = host || ENV['LANGFUSE_HOST'] || ENV['LANGFUSE_BASE_URL'] || Langfuse.configuration.host
|
|
@@ -41,28 +64,37 @@ module Langfuse
|
|
|
41
64
|
@retries = config_value(retries, nil, :retries) { 3 }
|
|
42
65
|
@flush_interval = config_value(flush_interval, 'LANGFUSE_FLUSH_INTERVAL', :flush_interval) { 5 }
|
|
43
66
|
@flush_at = config_value(flush_at, 'LANGFUSE_FLUSH_AT', :flush_at) { 15 }
|
|
67
|
+
@max_queue_size = config_value(max_queue_size, 'LANGFUSE_MAX_QUEUE_SIZE', :max_queue_size) { 10_000 }
|
|
44
68
|
@auto_flush = resolve_auto_flush(auto_flush)
|
|
45
|
-
@ingestion_mode = resolve_ingestion_mode(ingestion_mode)
|
|
46
69
|
@logger = logger || Langfuse.configuration.logger || build_default_logger
|
|
70
|
+
@ingestion_mode = resolve_ingestion_mode(ingestion_mode)
|
|
47
71
|
@environment = resolve_environment(environment)
|
|
48
72
|
@sample_rate = resolve_sample_rate(sample_rate)
|
|
49
73
|
@mask = resolve_mask(mask)
|
|
50
74
|
@shutdown_on_exit = shutdown_on_exit.nil? ? Langfuse.configuration.shutdown_on_exit : shutdown_on_exit
|
|
75
|
+
@http_adapter = http_adapter || Langfuse.configuration.http_adapter
|
|
51
76
|
@shutdown = false
|
|
52
77
|
|
|
53
78
|
raise AuthenticationError, 'Public key is required' unless @public_key
|
|
54
79
|
raise AuthenticationError, 'Secret key is required' unless @secret_key
|
|
55
80
|
|
|
56
|
-
|
|
57
|
-
@otel_connection = build_otel_connection if @ingestion_mode == :otel
|
|
58
|
-
@otel_exporter = OtelExporter.new(connection: @otel_connection, debug: @debug, logger: @logger) if @ingestion_mode == :otel
|
|
81
|
+
setup_transport
|
|
59
82
|
@event_queue = Concurrent::Array.new
|
|
83
|
+
@queue_mutex = Mutex.new
|
|
60
84
|
@flush_mutex = Mutex.new
|
|
61
85
|
@flush_condition = ConditionVariable.new
|
|
62
|
-
@
|
|
86
|
+
@dropped_events = 0
|
|
87
|
+
@prompt_cache = PromptCache.new
|
|
88
|
+
start_flush_thread if @auto_flush
|
|
63
89
|
register_shutdown_hook if @shutdown_on_exit
|
|
64
90
|
end
|
|
65
91
|
|
|
92
|
+
# Keep the secret key out of logs, console output and exception messages.
|
|
93
|
+
def inspect
|
|
94
|
+
"#<#{self.class.name} host=#{@host.inspect} public_key=#{@public_key.inspect} " \
|
|
95
|
+
"ingestion_mode=#{@ingestion_mode.inspect}>"
|
|
96
|
+
end
|
|
97
|
+
|
|
66
98
|
# Generate a trace ID matching the active ingestion mode
|
|
67
99
|
# (W3C 32-char hex for :otel, UUID for :legacy)
|
|
68
100
|
def generate_trace_id
|
|
@@ -118,91 +150,15 @@ module Langfuse
|
|
|
118
150
|
)
|
|
119
151
|
end
|
|
120
152
|
|
|
121
|
-
# Convenience methods for enhanced observation types
|
|
153
|
+
# Convenience methods for enhanced observation types: each is a span with a
|
|
154
|
+
# fixed as_type. (embedding keeps its own definition because it folds
|
|
155
|
+
# model/usage into metadata first.)
|
|
156
|
+
extend SpanWrappers
|
|
157
|
+
define_span_wrappers(evaluator_name: :evaluator_obs)
|
|
122
158
|
|
|
123
|
-
#
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
version: nil, **kwargs)
|
|
127
|
-
span(
|
|
128
|
-
trace_id: trace_id,
|
|
129
|
-
name: name,
|
|
130
|
-
start_time: start_time,
|
|
131
|
-
end_time: end_time,
|
|
132
|
-
input: input,
|
|
133
|
-
output: output,
|
|
134
|
-
metadata: metadata,
|
|
135
|
-
level: level,
|
|
136
|
-
status_message: status_message,
|
|
137
|
-
parent_observation_id: parent_observation_id,
|
|
138
|
-
version: version,
|
|
139
|
-
as_type: ObservationType::AGENT,
|
|
140
|
-
**kwargs
|
|
141
|
-
)
|
|
142
|
-
end
|
|
143
|
-
|
|
144
|
-
# Create a tool observation (wrapper around span with as_type: 'tool')
|
|
145
|
-
def tool(trace_id:, name: nil, start_time: nil, end_time: nil, input: nil, output: nil,
|
|
146
|
-
metadata: nil, level: nil, status_message: nil, parent_observation_id: nil,
|
|
147
|
-
version: nil, **kwargs)
|
|
148
|
-
span(
|
|
149
|
-
trace_id: trace_id,
|
|
150
|
-
name: name,
|
|
151
|
-
start_time: start_time,
|
|
152
|
-
end_time: end_time,
|
|
153
|
-
input: input,
|
|
154
|
-
output: output,
|
|
155
|
-
metadata: metadata,
|
|
156
|
-
level: level,
|
|
157
|
-
status_message: status_message,
|
|
158
|
-
parent_observation_id: parent_observation_id,
|
|
159
|
-
version: version,
|
|
160
|
-
as_type: ObservationType::TOOL,
|
|
161
|
-
**kwargs
|
|
162
|
-
)
|
|
163
|
-
end
|
|
164
|
-
|
|
165
|
-
# Create a chain observation (wrapper around span with as_type: 'chain')
|
|
166
|
-
def chain(trace_id:, name: nil, start_time: nil, end_time: nil, input: nil, output: nil,
|
|
167
|
-
metadata: nil, level: nil, status_message: nil, parent_observation_id: nil,
|
|
168
|
-
version: nil, **kwargs)
|
|
169
|
-
span(
|
|
170
|
-
trace_id: trace_id,
|
|
171
|
-
name: name,
|
|
172
|
-
start_time: start_time,
|
|
173
|
-
end_time: end_time,
|
|
174
|
-
input: input,
|
|
175
|
-
output: output,
|
|
176
|
-
metadata: metadata,
|
|
177
|
-
level: level,
|
|
178
|
-
status_message: status_message,
|
|
179
|
-
parent_observation_id: parent_observation_id,
|
|
180
|
-
version: version,
|
|
181
|
-
as_type: ObservationType::CHAIN,
|
|
182
|
-
**kwargs
|
|
183
|
-
)
|
|
184
|
-
end
|
|
185
|
-
|
|
186
|
-
# Create a retriever observation (wrapper around span with as_type: 'retriever')
|
|
187
|
-
def retriever(trace_id:, name: nil, start_time: nil, end_time: nil, input: nil, output: nil,
|
|
188
|
-
metadata: nil, level: nil, status_message: nil, parent_observation_id: nil,
|
|
189
|
-
version: nil, **kwargs)
|
|
190
|
-
span(
|
|
191
|
-
trace_id: trace_id,
|
|
192
|
-
name: name,
|
|
193
|
-
start_time: start_time,
|
|
194
|
-
end_time: end_time,
|
|
195
|
-
input: input,
|
|
196
|
-
output: output,
|
|
197
|
-
metadata: metadata,
|
|
198
|
-
level: level,
|
|
199
|
-
status_message: status_message,
|
|
200
|
-
parent_observation_id: parent_observation_id,
|
|
201
|
-
version: version,
|
|
202
|
-
as_type: ObservationType::RETRIEVER,
|
|
203
|
-
**kwargs
|
|
204
|
-
)
|
|
205
|
-
end
|
|
159
|
+
# `evaluator` matches Trace/Span/Generation; `evaluator_obs` is kept for
|
|
160
|
+
# callers that adopted the older name.
|
|
161
|
+
alias evaluator evaluator_obs
|
|
206
162
|
|
|
207
163
|
# Create an embedding observation (wrapper around span with as_type: 'embedding')
|
|
208
164
|
def embedding(trace_id:, name: nil, start_time: nil, end_time: nil, input: nil, output: nil,
|
|
@@ -228,48 +184,6 @@ module Langfuse
|
|
|
228
184
|
)
|
|
229
185
|
end
|
|
230
186
|
|
|
231
|
-
# Create an evaluator observation (wrapper around span with as_type: 'evaluator')
|
|
232
|
-
def evaluator_obs(trace_id:, name: nil, start_time: nil, end_time: nil, input: nil, output: nil,
|
|
233
|
-
metadata: nil, level: nil, status_message: nil, parent_observation_id: nil,
|
|
234
|
-
version: nil, **kwargs)
|
|
235
|
-
span(
|
|
236
|
-
trace_id: trace_id,
|
|
237
|
-
name: name,
|
|
238
|
-
start_time: start_time,
|
|
239
|
-
end_time: end_time,
|
|
240
|
-
input: input,
|
|
241
|
-
output: output,
|
|
242
|
-
metadata: metadata,
|
|
243
|
-
level: level,
|
|
244
|
-
status_message: status_message,
|
|
245
|
-
parent_observation_id: parent_observation_id,
|
|
246
|
-
version: version,
|
|
247
|
-
as_type: ObservationType::EVALUATOR,
|
|
248
|
-
**kwargs
|
|
249
|
-
)
|
|
250
|
-
end
|
|
251
|
-
|
|
252
|
-
# Create a guardrail observation (wrapper around span with as_type: 'guardrail')
|
|
253
|
-
def guardrail(trace_id:, name: nil, start_time: nil, end_time: nil, input: nil, output: nil,
|
|
254
|
-
metadata: nil, level: nil, status_message: nil, parent_observation_id: nil,
|
|
255
|
-
version: nil, **kwargs)
|
|
256
|
-
span(
|
|
257
|
-
trace_id: trace_id,
|
|
258
|
-
name: name,
|
|
259
|
-
start_time: start_time,
|
|
260
|
-
end_time: end_time,
|
|
261
|
-
input: input,
|
|
262
|
-
output: output,
|
|
263
|
-
metadata: metadata,
|
|
264
|
-
level: level,
|
|
265
|
-
status_message: status_message,
|
|
266
|
-
parent_observation_id: parent_observation_id,
|
|
267
|
-
version: version,
|
|
268
|
-
as_type: ObservationType::GUARDRAIL,
|
|
269
|
-
**kwargs
|
|
270
|
-
)
|
|
271
|
-
end
|
|
272
|
-
|
|
273
187
|
# Generation operations
|
|
274
188
|
def generation(trace_id:, id: nil, name: nil, start_time: nil, end_time: nil, completion_start_time: nil,
|
|
275
189
|
model: nil, model_parameters: nil, input: nil, output: nil, usage: nil,
|
|
@@ -322,42 +236,24 @@ module Langfuse
|
|
|
322
236
|
end
|
|
323
237
|
|
|
324
238
|
# Prompt operations
|
|
325
|
-
def get_prompt(name, version: nil, label: nil, cache_ttl_seconds: 60)
|
|
239
|
+
def get_prompt(name, version: nil, label: nil, cache_ttl_seconds: 60, retries: nil)
|
|
326
240
|
cache_key = "prompt:#{name}:#{version}:#{label}"
|
|
241
|
+
cached = @prompt_cache.read(cache_key, cache_ttl_seconds)
|
|
242
|
+
return cached if cached
|
|
327
243
|
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
params[:version] = version if version
|
|
336
|
-
params[:label] = label if label
|
|
337
|
-
|
|
338
|
-
@logger.debug("Making request to: #{@host}#{path} with params: #{params}")
|
|
339
|
-
|
|
340
|
-
response = get(path, params)
|
|
341
|
-
|
|
342
|
-
@logger.debug("Response status: #{response.status}")
|
|
343
|
-
@logger.debug("Response headers: #{response.headers}")
|
|
344
|
-
@logger.debug("Response body type: #{response.body.class}")
|
|
244
|
+
begin
|
|
245
|
+
prompt = request_prompt(name, version: version, label: label, retries: retries)
|
|
246
|
+
rescue StandardError => e
|
|
247
|
+
# An expired entry beats no prompt at all: serving it keeps the
|
|
248
|
+
# application running through a Langfuse outage.
|
|
249
|
+
stale = @prompt_cache.read_stale(cache_key)
|
|
250
|
+
raise unless stale
|
|
345
251
|
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
@logger.debug('Received HTML response instead of JSON:')
|
|
349
|
-
@logger.debug(response.body[0..200])
|
|
350
|
-
raise APIError,
|
|
351
|
-
'Received HTML response instead of JSON. This usually indicates a 404 error or incorrect API endpoint.'
|
|
252
|
+
@logger.warn("Langfuse prompt fetch failed (#{name}), serving the cached copy: #{e.message}")
|
|
253
|
+
return stale
|
|
352
254
|
end
|
|
353
255
|
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
# Cache the prompt
|
|
357
|
-
@prompt_cache ||= {}
|
|
358
|
-
@prompt_cache[cache_key] = { prompt: prompt, cached_at: Time.now }
|
|
359
|
-
|
|
360
|
-
prompt
|
|
256
|
+
@prompt_cache.write(cache_key, prompt)
|
|
361
257
|
end
|
|
362
258
|
|
|
363
259
|
def create_prompt(name:, prompt:, labels: [], config: {}, **kwargs)
|
|
@@ -405,7 +301,7 @@ module Langfuse
|
|
|
405
301
|
alias create_score score
|
|
406
302
|
|
|
407
303
|
# Event queue management
|
|
408
|
-
def enqueue_event(type, body)
|
|
304
|
+
def enqueue_event(type, body, trace_ref: nil)
|
|
409
305
|
# 验证事件类型是否有效
|
|
410
306
|
valid_types = %w[
|
|
411
307
|
trace-create trace-update
|
|
@@ -416,13 +312,15 @@ module Langfuse
|
|
|
416
312
|
]
|
|
417
313
|
|
|
418
314
|
unless valid_types.include?(type)
|
|
419
|
-
@logger.debug
|
|
315
|
+
@logger.debug { "Warning: Invalid event type '#{type}'. Skipping event." }
|
|
420
316
|
return
|
|
421
317
|
end
|
|
422
318
|
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
319
|
+
# Runs before the event is queued so events inherited from a parent process
|
|
320
|
+
# are discarded without dropping the event we are about to enqueue.
|
|
321
|
+
ensure_flush_thread
|
|
322
|
+
|
|
323
|
+
prepared_body = prepare_queued_body(body)
|
|
426
324
|
|
|
427
325
|
return unless sampled_event?(type, prepared_body)
|
|
428
326
|
|
|
@@ -432,42 +330,28 @@ module Langfuse
|
|
|
432
330
|
timestamp: Utils.current_timestamp,
|
|
433
331
|
body: prepared_body
|
|
434
332
|
}
|
|
333
|
+
event[:trace_ref] = trace_ref if trace_ref
|
|
435
334
|
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
if
|
|
440
|
-
|
|
441
|
-
existing_event[:type] == 'trace-create' &&
|
|
442
|
-
(existing_event[:body]['id'] == trace_id || existing_event[:body][:id] == trace_id)
|
|
443
|
-
end
|
|
444
|
-
|
|
445
|
-
if existing_event_index
|
|
446
|
-
# 更新现有的 trace-create 事件
|
|
447
|
-
@event_queue[existing_event_index][:body].merge!(event[:body])
|
|
448
|
-
@event_queue[existing_event_index][:timestamp] = event[:timestamp]
|
|
449
|
-
@logger.debug("Updated existing trace-create event for trace_id: #{trace_id}")
|
|
450
|
-
else
|
|
451
|
-
# 如果没找到对应的 trace-create 事件,将 trace-update 转换为 trace-create
|
|
452
|
-
event[:type] = 'trace-create'
|
|
453
|
-
@event_queue << event
|
|
454
|
-
@logger.debug("Converted trace-update to trace-create for trace_id: #{trace_id}")
|
|
455
|
-
end
|
|
335
|
+
# The queue is drained under the same lock, so a concurrent flush can no
|
|
336
|
+
# longer take an event out between finding it and merging into it.
|
|
337
|
+
queued = @queue_mutex.synchronize do
|
|
338
|
+
if type == 'trace-update'
|
|
339
|
+
merge_or_queue_trace_update?(event)
|
|
456
340
|
else
|
|
457
|
-
|
|
341
|
+
push_event?(event)
|
|
458
342
|
end
|
|
459
|
-
else
|
|
460
|
-
@event_queue << event
|
|
461
343
|
end
|
|
462
|
-
|
|
344
|
+
return unless queued
|
|
345
|
+
|
|
346
|
+
@logger.debug { "Enqueued event: #{type}" }
|
|
463
347
|
|
|
464
348
|
request_flush if @auto_flush && @event_queue.length >= @flush_at
|
|
465
349
|
end
|
|
466
350
|
|
|
467
351
|
def flush
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
352
|
+
events = @queue_mutex.synchronize do
|
|
353
|
+
@event_queue.empty? ? [] : @event_queue.shift(@event_queue.length)
|
|
354
|
+
end
|
|
471
355
|
return if events.empty?
|
|
472
356
|
|
|
473
357
|
send_batch(events)
|
|
@@ -477,12 +361,42 @@ module Langfuse
|
|
|
477
361
|
return if @shutdown
|
|
478
362
|
|
|
479
363
|
@shutdown = true
|
|
480
|
-
|
|
364
|
+
stop_flush_thread
|
|
481
365
|
flush unless @event_queue.empty?
|
|
482
366
|
end
|
|
483
367
|
|
|
484
368
|
private
|
|
485
369
|
|
|
370
|
+
def setup_transport
|
|
371
|
+
@connection = build_connection
|
|
372
|
+
return unless @ingestion_mode == :otel
|
|
373
|
+
|
|
374
|
+
@otel_connection = build_otel_connection
|
|
375
|
+
@otel_exporter = OtelExporter.new(connection: @otel_connection, debug: @debug, logger: @logger)
|
|
376
|
+
end
|
|
377
|
+
|
|
378
|
+
def request_prompt(name, version:, label:, retries: nil)
|
|
379
|
+
path = "/api/public/v2/prompts/#{Utils.url_encode(name)}"
|
|
380
|
+
params = {}
|
|
381
|
+
params[:version] = version if version
|
|
382
|
+
params[:label] = label if label
|
|
383
|
+
|
|
384
|
+
@logger.debug { "Making request to: #{@host}#{path} with params: #{params}" }
|
|
385
|
+
|
|
386
|
+
response = request(:get, path, params: params, retries: retries)
|
|
387
|
+
|
|
388
|
+
@logger.debug { "Response status: #{response.status}, body type: #{response.body.class}" }
|
|
389
|
+
|
|
390
|
+
# Check if response body is a string (HTML) instead of parsed JSON
|
|
391
|
+
if response.body.is_a?(String) && response.body.include?('<!DOCTYPE html>')
|
|
392
|
+
@logger.debug { "Received HTML response instead of JSON: #{response.body[0..200]}" }
|
|
393
|
+
raise APIError,
|
|
394
|
+
'Received HTML response instead of JSON. This usually indicates a 404 error or incorrect API endpoint.'
|
|
395
|
+
end
|
|
396
|
+
|
|
397
|
+
Prompt.new(response.body)
|
|
398
|
+
end
|
|
399
|
+
|
|
486
400
|
def build_default_logger
|
|
487
401
|
logger = Logger.new(StdoutLogDevice.new)
|
|
488
402
|
logger.level = @debug ? Logger::DEBUG : Logger::WARN
|
|
@@ -499,7 +413,10 @@ module Langfuse
|
|
|
499
413
|
|
|
500
414
|
if env_key
|
|
501
415
|
env_val = ENV.fetch(env_key, nil)
|
|
502
|
-
|
|
416
|
+
if env_val && %i[flush_interval flush_at timeout retries max_queue_size].include?(config_attr)
|
|
417
|
+
return env_val.to_i
|
|
418
|
+
end
|
|
419
|
+
|
|
503
420
|
return env_val if env_val
|
|
504
421
|
end
|
|
505
422
|
|
|
@@ -557,6 +474,16 @@ module Langfuse
|
|
|
557
474
|
end
|
|
558
475
|
end
|
|
559
476
|
|
|
477
|
+
# Camelize top-level keys, inject the default environment, then apply the
|
|
478
|
+
# mask. Every body that enters the queue — including a trace_ref rebuilt
|
|
479
|
+
# after the matching create has already flushed — must go through this.
|
|
480
|
+
def prepare_queued_body(body)
|
|
481
|
+
prepared = Utils.prepare_event_body(body)
|
|
482
|
+
inject_default_environment(prepared)
|
|
483
|
+
apply_mask(prepared)
|
|
484
|
+
prepared
|
|
485
|
+
end
|
|
486
|
+
|
|
560
487
|
def inject_default_environment(body)
|
|
561
488
|
return unless @environment
|
|
562
489
|
return if body.key?('environment')
|
|
@@ -604,6 +531,128 @@ module Langfuse
|
|
|
604
531
|
@flush_mutex.synchronize { @flush_condition.signal }
|
|
605
532
|
end
|
|
606
533
|
|
|
534
|
+
# Merge a trace-update into the queued trace-create for the same trace.
|
|
535
|
+
# Returns whether the queue changed. Callers must hold @queue_mutex.
|
|
536
|
+
def merge_or_queue_trace_update?(event)
|
|
537
|
+
trace_id = event[:body]['id']
|
|
538
|
+
|
|
539
|
+
unless trace_id
|
|
540
|
+
@logger.debug { 'Warning: trace-update event missing trace_id, skipping' }
|
|
541
|
+
return false
|
|
542
|
+
end
|
|
543
|
+
|
|
544
|
+
position = @event_queue.find_index do |queued_event|
|
|
545
|
+
queued_event[:type] == 'trace-create' && queued_event[:body]['id'] == trace_id
|
|
546
|
+
end
|
|
547
|
+
|
|
548
|
+
unless position
|
|
549
|
+
# Nothing left to merge into (already flushed): send it as a create so
|
|
550
|
+
# the server can upsert. Use the trace's full state (via :trace_ref) to
|
|
551
|
+
# avoid sending a partial body that would produce a broken observation.
|
|
552
|
+
event[:type] = 'trace-create'
|
|
553
|
+
if event[:trace_ref]
|
|
554
|
+
# to_dict is the live instance state (symbol keys, unmasked). Re-run
|
|
555
|
+
# the same prepare/env/mask path as enqueue_event so the API still
|
|
556
|
+
# sees camelCase keys, the default environment, and redacted PII.
|
|
557
|
+
event[:body] = prepare_queued_body(event[:trace_ref].to_dict)
|
|
558
|
+
event.delete(:trace_ref)
|
|
559
|
+
end
|
|
560
|
+
@logger.debug { "Converted trace-update to trace-create for trace_id: #{trace_id}" }
|
|
561
|
+
return push_event?(event)
|
|
562
|
+
end
|
|
563
|
+
|
|
564
|
+
@event_queue[position][:body].merge!(event[:body])
|
|
565
|
+
@event_queue[position][:timestamp] = event[:timestamp]
|
|
566
|
+
@logger.debug { "Updated existing trace-create event for trace_id: #{trace_id}" }
|
|
567
|
+
true
|
|
568
|
+
end
|
|
569
|
+
|
|
570
|
+
# Append an event, evicting the oldest ones when the queue is full. Without a
|
|
571
|
+
# bound, an unreachable Langfuse would grow the queue until the process dies.
|
|
572
|
+
# Callers must hold @queue_mutex. Returns whether the event was queued.
|
|
573
|
+
def push_event?(event)
|
|
574
|
+
dropped = 0
|
|
575
|
+
while @event_queue.length >= @max_queue_size
|
|
576
|
+
@event_queue.shift
|
|
577
|
+
dropped += 1
|
|
578
|
+
end
|
|
579
|
+
|
|
580
|
+
if dropped.positive?
|
|
581
|
+
@dropped_events += dropped
|
|
582
|
+
warn_dropped_events
|
|
583
|
+
end
|
|
584
|
+
|
|
585
|
+
@event_queue << event
|
|
586
|
+
true
|
|
587
|
+
end
|
|
588
|
+
|
|
589
|
+
def warn_dropped_events
|
|
590
|
+
return unless @dropped_events == 1 || (@dropped_events % DROPPED_EVENTS_WARN_INTERVAL).zero?
|
|
591
|
+
|
|
592
|
+
@logger.warn("Langfuse event queue is full (max_queue_size=#{@max_queue_size}); " \
|
|
593
|
+
"dropped #{@dropped_events} oldest events so far")
|
|
594
|
+
end
|
|
595
|
+
|
|
596
|
+
# Put events back for the next flush. Permanent failures are dropped instead:
|
|
597
|
+
# they would fail again on every flush and block the queue indefinitely.
|
|
598
|
+
# Events are prepended so they are retried before any newly enqueued events,
|
|
599
|
+
# preserving the original chronological order.
|
|
600
|
+
def requeue_events(events, error)
|
|
601
|
+
return if events.empty?
|
|
602
|
+
|
|
603
|
+
if permanent_failure?(error)
|
|
604
|
+
@logger.warn("Langfuse dropped #{events.length} events after a permanent failure " \
|
|
605
|
+
"(#{error.class}): #{error.message}")
|
|
606
|
+
return
|
|
607
|
+
end
|
|
608
|
+
|
|
609
|
+
@queue_mutex.synchronize { events.reverse_each { |event| prepend_event(event) } }
|
|
610
|
+
end
|
|
611
|
+
|
|
612
|
+
# Insert an event at the head of the queue, evicting the oldest (tail) if full.
|
|
613
|
+
# Callers must hold @queue_mutex.
|
|
614
|
+
def prepend_event(event)
|
|
615
|
+
if @event_queue.length >= @max_queue_size
|
|
616
|
+
@event_queue.pop
|
|
617
|
+
@dropped_events += 1
|
|
618
|
+
warn_dropped_events
|
|
619
|
+
end
|
|
620
|
+
@event_queue.unshift(event)
|
|
621
|
+
end
|
|
622
|
+
|
|
623
|
+
def permanent_failure?(error)
|
|
624
|
+
error.is_a?(ValidationError) || error.is_a?(AuthenticationError)
|
|
625
|
+
end
|
|
626
|
+
|
|
627
|
+
# Threads do not survive fork. Recreate the flush thread in the child and drop
|
|
628
|
+
# the events it inherited, which the parent process still flushes itself.
|
|
629
|
+
def ensure_flush_thread
|
|
630
|
+
return unless @auto_flush
|
|
631
|
+
return if @flush_thread_pid == Process.pid && @flush_thread&.alive?
|
|
632
|
+
|
|
633
|
+
if @flush_thread_pid && @flush_thread_pid != Process.pid
|
|
634
|
+
inherited = @queue_mutex.synchronize { @event_queue.shift(@event_queue.length) }
|
|
635
|
+
@logger.debug { "Dropped #{inherited.length} events inherited from pid #{@flush_thread_pid}" }
|
|
636
|
+
end
|
|
637
|
+
|
|
638
|
+
start_flush_thread
|
|
639
|
+
end
|
|
640
|
+
|
|
641
|
+
# Let the flush thread finish the send it is in the middle of; killing it
|
|
642
|
+
# would lose the events it already drained from the queue.
|
|
643
|
+
def stop_flush_thread
|
|
644
|
+
thread = @flush_thread
|
|
645
|
+
return unless thread
|
|
646
|
+
|
|
647
|
+
@stop_flushing = true
|
|
648
|
+
@flush_thread = nil
|
|
649
|
+
request_flush
|
|
650
|
+
return if thread.join(FLUSH_THREAD_JOIN_TIMEOUT)
|
|
651
|
+
|
|
652
|
+
@logger.warn("Langfuse flush thread did not stop within #{FLUSH_THREAD_JOIN_TIMEOUT}s; terminating it")
|
|
653
|
+
thread.kill
|
|
654
|
+
end
|
|
655
|
+
|
|
607
656
|
def debug_event_data(events)
|
|
608
657
|
return unless @debug
|
|
609
658
|
|
|
@@ -655,24 +704,56 @@ module Langfuse
|
|
|
655
704
|
end
|
|
656
705
|
|
|
657
706
|
def send_batch_legacy(valid_events)
|
|
658
|
-
|
|
659
|
-
response = nil
|
|
660
|
-
|
|
661
|
-
chunks.each_with_index do |chunk, index|
|
|
662
|
-
batch_data = build_batch_data(chunk)
|
|
663
|
-
@logger.debug("Sending batch data: #{batch_data}")
|
|
707
|
+
payload = encode_batch(valid_events)
|
|
664
708
|
|
|
709
|
+
# Common case: the whole batch fits, so this single JSON pass covers both
|
|
710
|
+
# the size check and the request body. Only an oversized payload pays for
|
|
711
|
+
# the per-event accounting in chunk_events.
|
|
712
|
+
if payload && payload.bytesize <= MAX_BATCH_SIZE_BYTES
|
|
665
713
|
begin
|
|
666
|
-
|
|
667
|
-
log_ingestion_errors(response)
|
|
668
|
-
@logger.debug("Flushed #{chunk.length} events (legacy)")
|
|
714
|
+
return post_ingestion(payload, valid_events.length)
|
|
669
715
|
rescue StandardError => e
|
|
670
|
-
@logger.debug
|
|
671
|
-
|
|
716
|
+
@logger.debug { "Failed to flush events: #{e.message}" }
|
|
717
|
+
requeue_events(valid_events, e)
|
|
672
718
|
raise
|
|
673
719
|
end
|
|
674
720
|
end
|
|
675
721
|
|
|
722
|
+
send_batch_legacy_chunked(valid_events)
|
|
723
|
+
end
|
|
724
|
+
|
|
725
|
+
def send_batch_legacy_chunked(valid_events)
|
|
726
|
+
chunks = chunk_events(valid_events)
|
|
727
|
+
response = nil
|
|
728
|
+
|
|
729
|
+
chunks.each_with_index do |chunk, index|
|
|
730
|
+
response = post_ingestion(encode_batch(chunk) || build_batch_data(chunk), chunk.length)
|
|
731
|
+
rescue StandardError => e
|
|
732
|
+
@logger.debug { "Failed to flush events: #{e.message}" }
|
|
733
|
+
requeue_events(chunks[index..].flatten(1), e)
|
|
734
|
+
raise
|
|
735
|
+
end
|
|
736
|
+
|
|
737
|
+
response
|
|
738
|
+
end
|
|
739
|
+
|
|
740
|
+
# Faraday forwards a String body untouched, so a pre-serialized batch is not
|
|
741
|
+
# encoded a second time by the JSON middleware.
|
|
742
|
+
def encode_batch(events)
|
|
743
|
+
JSON.generate(build_batch_data(events))
|
|
744
|
+
rescue StandardError => e
|
|
745
|
+
@logger.debug { "Could not pre-serialize the batch, letting Faraday encode it: #{e.message}" }
|
|
746
|
+
nil
|
|
747
|
+
end
|
|
748
|
+
|
|
749
|
+
def post_ingestion(payload, event_count)
|
|
750
|
+
# Block form: interpolating a multi-megabyte batch would cost the same
|
|
751
|
+
# whether or not debug logging is enabled.
|
|
752
|
+
@logger.debug { "Sending batch data: #{payload}" }
|
|
753
|
+
|
|
754
|
+
response = post('/api/public/ingestion', payload)
|
|
755
|
+
log_ingestion_errors(response)
|
|
756
|
+
@logger.debug { "Flushed #{event_count} events (legacy)" }
|
|
676
757
|
response
|
|
677
758
|
end
|
|
678
759
|
|
|
@@ -682,18 +763,21 @@ module Langfuse
|
|
|
682
763
|
response = nil
|
|
683
764
|
|
|
684
765
|
unless otel_events.empty?
|
|
685
|
-
@logger.debug
|
|
766
|
+
@logger.debug { "Sending #{otel_events.length} events via OTEL" }
|
|
767
|
+
chunks = chunk_events(otel_events)
|
|
686
768
|
|
|
687
|
-
|
|
688
|
-
response =
|
|
689
|
-
handle_response(response)
|
|
690
|
-
@logger.debug("Flushed #{otel_events.length} events (otel)")
|
|
769
|
+
chunks.each_with_index do |chunk, index|
|
|
770
|
+
response = export_otel_chunk(chunk)
|
|
691
771
|
rescue StandardError => e
|
|
692
|
-
@logger.debug
|
|
693
|
-
# Re-queue
|
|
694
|
-
#
|
|
695
|
-
|
|
696
|
-
|
|
772
|
+
@logger.debug { "Failed to flush OTEL events: #{e.message}" }
|
|
773
|
+
# Re-queue the not-yet-sent OTel chunks. Permanent failures (4xx)
|
|
774
|
+
# are dropped by requeue_events; transient ones are re-queued.
|
|
775
|
+
requeue_events(chunks[index..].flatten(1), e)
|
|
776
|
+
# Score events were never attempted and must always be re-queued,
|
|
777
|
+
# regardless of why the OTel chunk failed.
|
|
778
|
+
unless score_events.empty?
|
|
779
|
+
@queue_mutex.synchronize { score_events.each { |ev| prepend_event(ev) } }
|
|
780
|
+
end
|
|
697
781
|
raise
|
|
698
782
|
end
|
|
699
783
|
end
|
|
@@ -708,6 +792,56 @@ module Langfuse
|
|
|
708
792
|
response
|
|
709
793
|
end
|
|
710
794
|
|
|
795
|
+
# Export one chunk of events to the OTLP endpoint with retries for transient errors.
|
|
796
|
+
def export_otel_chunk(chunk, retries: nil)
|
|
797
|
+
allowed_retries = retries || @retries
|
|
798
|
+
attempt = 0
|
|
799
|
+
response = nil
|
|
800
|
+
|
|
801
|
+
begin
|
|
802
|
+
response = execute_otel_export(chunk)
|
|
803
|
+
handle_response(response)
|
|
804
|
+
log_otel_partial_success(response)
|
|
805
|
+
@logger.debug { "Flushed #{chunk.length} events (otel)" }
|
|
806
|
+
response
|
|
807
|
+
rescue Langfuse::Error => e
|
|
808
|
+
raise unless attempt < allowed_retries && retryable_error?(e, response)
|
|
809
|
+
|
|
810
|
+
attempt += 1
|
|
811
|
+
delay = retry_delay(attempt, response)
|
|
812
|
+
@logger.debug { "Retrying OTEL export in #{delay.round(2)}s (attempt #{attempt}/#{allowed_retries}): #{e.message}" }
|
|
813
|
+
response = nil
|
|
814
|
+
sleep(delay)
|
|
815
|
+
retry
|
|
816
|
+
end
|
|
817
|
+
end
|
|
818
|
+
|
|
819
|
+
def execute_otel_export(chunk)
|
|
820
|
+
@otel_exporter.export(chunk)
|
|
821
|
+
rescue Faraday::TimeoutError => e
|
|
822
|
+
raise TimeoutError, "Request timed out: #{e.message}"
|
|
823
|
+
rescue Faraday::ConnectionFailed => e
|
|
824
|
+
raise NetworkError, "Connection failed: #{e.message}"
|
|
825
|
+
rescue Faraday::Error => e
|
|
826
|
+
raise APIError, "OTEL export failed: #{e.message}"
|
|
827
|
+
end
|
|
828
|
+
|
|
829
|
+
# The OTLP endpoint answers 200 even when it rejected part of the payload.
|
|
830
|
+
def log_otel_partial_success(response)
|
|
831
|
+
body = response.respond_to?(:body) ? response.body : nil
|
|
832
|
+
return unless body.is_a?(Hash)
|
|
833
|
+
|
|
834
|
+
partial = body['partialSuccess'] || body[:partialSuccess]
|
|
835
|
+
return unless partial.is_a?(Hash)
|
|
836
|
+
|
|
837
|
+
rejected = (partial['rejectedSpans'] || partial[:rejectedSpans]).to_i
|
|
838
|
+
message = (partial['errorMessage'] || partial[:errorMessage]).to_s
|
|
839
|
+
return if rejected.zero? && message.empty?
|
|
840
|
+
|
|
841
|
+
details = " - #{message}" unless message.empty?
|
|
842
|
+
@logger.warn("Langfuse OTEL partial success: #{rejected} spans rejected#{details}")
|
|
843
|
+
end
|
|
844
|
+
|
|
711
845
|
# Align score references with the OTel-derived trace/span IDs so scores
|
|
712
846
|
# attach to the correct entities when ingesting via the OTel endpoint.
|
|
713
847
|
def normalize_otel_score_event(event)
|
|
@@ -777,26 +911,38 @@ module Langfuse
|
|
|
777
911
|
def start_flush_thread
|
|
778
912
|
return unless @auto_flush
|
|
779
913
|
|
|
780
|
-
|
|
781
|
-
|
|
914
|
+
@stop_flushing = false
|
|
915
|
+
@flush_thread_pid = Process.pid
|
|
916
|
+
@flush_thread = Thread.new do
|
|
917
|
+
until @stop_flushing
|
|
782
918
|
# Wait for the flush interval or an early wake-up (flush_at threshold)
|
|
783
919
|
@flush_mutex.synchronize { @flush_condition.wait(@flush_mutex, @flush_interval) }
|
|
920
|
+
break if @stop_flushing
|
|
921
|
+
|
|
784
922
|
begin
|
|
785
923
|
flush unless @event_queue.empty?
|
|
786
924
|
rescue StandardError => e
|
|
787
|
-
@logger.debug
|
|
925
|
+
@logger.debug { "Error in flush thread: #{e.message}" }
|
|
788
926
|
end
|
|
789
927
|
end
|
|
790
928
|
end
|
|
791
929
|
end
|
|
792
930
|
|
|
793
931
|
def resolve_ingestion_mode(explicit_mode)
|
|
794
|
-
return explicit_mode.to_sym if explicit_mode
|
|
795
|
-
|
|
796
932
|
env_mode = ENV.fetch('LANGFUSE_INGESTION_MODE', nil)
|
|
797
|
-
|
|
933
|
+
env_mode = nil if env_mode&.empty?
|
|
934
|
+
mode = explicit_mode || env_mode || Langfuse.configuration.ingestion_mode
|
|
935
|
+
return :legacy if mode.nil?
|
|
798
936
|
|
|
799
|
-
|
|
937
|
+
# Unrecognized values used to silently behave like :legacy, so a typo in
|
|
938
|
+
# LANGFUSE_INGESTION_MODE looked like a working v4 setup.
|
|
939
|
+
mode = mode.to_s.downcase.to_sym
|
|
940
|
+
return mode if INGESTION_MODES.include?(mode)
|
|
941
|
+
|
|
942
|
+
@logger.warn do
|
|
943
|
+
"Unknown Langfuse ingestion_mode #{mode.inspect}, expected one of #{INGESTION_MODES.join(', ')}. Using :legacy."
|
|
944
|
+
end
|
|
945
|
+
:legacy
|
|
800
946
|
end
|
|
801
947
|
|
|
802
948
|
def build_connection
|
|
@@ -817,11 +963,24 @@ module Langfuse
|
|
|
817
963
|
# 添加调试日志
|
|
818
964
|
conn.response :logger if @debug
|
|
819
965
|
|
|
820
|
-
|
|
821
|
-
conn.adapter Faraday.default_adapter
|
|
966
|
+
apply_http_adapter(conn)
|
|
822
967
|
end
|
|
823
968
|
end
|
|
824
969
|
|
|
970
|
+
# The default net_http adapter opens and closes a connection per request.
|
|
971
|
+
# `http_adapter` lets an application swap in a keep-alive adapter (for
|
|
972
|
+
# example `:net_http_persistent`) without this gem depending on it; an
|
|
973
|
+
# adapter that is not installed falls back instead of breaking tracing.
|
|
974
|
+
def apply_http_adapter(conn)
|
|
975
|
+
return conn.adapter(Faraday.default_adapter) if @http_adapter.nil?
|
|
976
|
+
|
|
977
|
+
conn.adapter(*Array(@http_adapter))
|
|
978
|
+
rescue StandardError => e
|
|
979
|
+
@logger.warn("Langfuse could not use the #{@http_adapter.inspect} Faraday adapter " \
|
|
980
|
+
"(#{e.message}); falling back to #{Faraday.default_adapter.inspect}")
|
|
981
|
+
conn.adapter(Faraday.default_adapter)
|
|
982
|
+
end
|
|
983
|
+
|
|
825
984
|
# Build a separate Faraday connection for OTEL with the v4 ingestion header.
|
|
826
985
|
def build_otel_connection
|
|
827
986
|
Faraday.new(url: @host) do |conn|
|
|
@@ -834,7 +993,7 @@ module Langfuse
|
|
|
834
993
|
|
|
835
994
|
conn.options.timeout = @timeout
|
|
836
995
|
conn.response :logger if @debug
|
|
837
|
-
conn
|
|
996
|
+
apply_http_adapter(conn)
|
|
838
997
|
end
|
|
839
998
|
end
|
|
840
999
|
|
|
@@ -859,31 +1018,82 @@ module Langfuse
|
|
|
859
1018
|
request(:patch, path, json: data)
|
|
860
1019
|
end
|
|
861
1020
|
|
|
862
|
-
def request(method, path, params: {}, json: nil)
|
|
863
|
-
|
|
1021
|
+
def request(method, path, params: {}, json: nil, retries: nil)
|
|
1022
|
+
allowed_retries = retries || @retries
|
|
1023
|
+
attempt = 0
|
|
1024
|
+
response = nil
|
|
864
1025
|
|
|
865
1026
|
begin
|
|
866
|
-
response =
|
|
867
|
-
req.url path
|
|
868
|
-
req.params = params if params.any?
|
|
869
|
-
req.body = json if json
|
|
870
|
-
end
|
|
871
|
-
|
|
1027
|
+
response = execute_request(method, path, params, json)
|
|
872
1028
|
handle_response(response)
|
|
873
|
-
rescue
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
1029
|
+
rescue Langfuse::Error => e
|
|
1030
|
+
# Typed errors raised by handle_response (401/404/429/4xx/5xx) keep their
|
|
1031
|
+
# class so callers can rescue AuthenticationError/RateLimitError etc.
|
|
1032
|
+
raise unless attempt < allowed_retries && retryable_error?(e, response)
|
|
1033
|
+
|
|
1034
|
+
attempt += 1
|
|
1035
|
+
delay = retry_delay(attempt, response)
|
|
1036
|
+
@logger.debug { "Retrying #{method.upcase} #{path} in #{delay.round(2)}s (attempt #{attempt}/#{allowed_retries}): #{e.message}" }
|
|
1037
|
+
response = nil
|
|
1038
|
+
sleep(delay)
|
|
1039
|
+
retry
|
|
882
1040
|
rescue StandardError => e
|
|
883
1041
|
raise APIError, "Request failed: #{e.message}"
|
|
884
1042
|
end
|
|
885
1043
|
end
|
|
886
1044
|
|
|
1045
|
+
def execute_request(method, path, params, json)
|
|
1046
|
+
@connection.send(method) do |req|
|
|
1047
|
+
req.url path
|
|
1048
|
+
req.params = params if params.any?
|
|
1049
|
+
req.body = json if json
|
|
1050
|
+
end
|
|
1051
|
+
rescue Faraday::TimeoutError => e
|
|
1052
|
+
raise TimeoutError, "Request timed out: #{e.message}"
|
|
1053
|
+
rescue Faraday::ConnectionFailed => e
|
|
1054
|
+
raise NetworkError, "Connection failed: #{e.message}"
|
|
1055
|
+
end
|
|
1056
|
+
|
|
1057
|
+
# Transient failures worth another attempt. Authentication and validation
|
|
1058
|
+
# errors would fail identically on a retry, so they are raised immediately.
|
|
1059
|
+
def retryable_error?(error, response)
|
|
1060
|
+
case error
|
|
1061
|
+
when TimeoutError, NetworkError, RateLimitError
|
|
1062
|
+
true
|
|
1063
|
+
when APIError
|
|
1064
|
+
response.respond_to?(:status) && response.status >= 500
|
|
1065
|
+
else
|
|
1066
|
+
false
|
|
1067
|
+
end
|
|
1068
|
+
end
|
|
1069
|
+
|
|
1070
|
+
# Honor the server's Retry-After when present, otherwise back off
|
|
1071
|
+
# exponentially with jitter.
|
|
1072
|
+
def retry_delay(attempt, response)
|
|
1073
|
+
server_delay = retry_after_seconds(response)
|
|
1074
|
+
return server_delay if server_delay
|
|
1075
|
+
|
|
1076
|
+
backoff = [RETRY_BASE_DELAY_SECONDS * (2**(attempt - 1)), MAX_RETRY_DELAY_SECONDS].min
|
|
1077
|
+
backoff * (0.5 + (rand * 0.5))
|
|
1078
|
+
end
|
|
1079
|
+
|
|
1080
|
+
def retry_after_seconds(response)
|
|
1081
|
+
raw = response.respond_to?(:headers) ? response.headers&.[]('retry-after') : nil
|
|
1082
|
+
return nil if raw.nil? || raw.to_s.strip.empty?
|
|
1083
|
+
|
|
1084
|
+
seconds = Float(raw, exception: false) || http_date_delay(raw)
|
|
1085
|
+
return nil unless seconds
|
|
1086
|
+
|
|
1087
|
+
seconds.clamp(0, MAX_RETRY_DELAY_SECONDS)
|
|
1088
|
+
end
|
|
1089
|
+
|
|
1090
|
+
# Retry-After may also be an HTTP date instead of a number of seconds.
|
|
1091
|
+
def http_date_delay(raw)
|
|
1092
|
+
Time.httpdate(raw.to_s) - Time.now
|
|
1093
|
+
rescue ArgumentError
|
|
1094
|
+
nil
|
|
1095
|
+
end
|
|
1096
|
+
|
|
887
1097
|
def handle_response(response)
|
|
888
1098
|
@logger.debug("Handling response with status: #{response.status}")
|
|
889
1099
|
|