0
0
Fork 0
mirror of https://github.com/discourse/discourse.git synced 2026-08-06 13:08:40 +08:00
discourse/plugins/discourse-ai/lib/completions/cancel_manager.rb
Sam 3d737f966e
FEATURE: retry transient and rate-limited LLM completion requests (#41237)
Adds automatic retries to the completions endpoint base so transient
failures no longer surface immediately to callers. Rate limits (429)
get their own backoff budget honoring Retry-After (capped at 60s),
while 408/409/5xx and network errors (timeouts, connection
resets, SSL errors) share a separate transient budget. Retry waits are
bounded, jittered, and interruptible via the cancel manager so both
synchronous and background completions stay responsive.

Each retried attempt's HTTP status is recorded in the new
AiApiAuditLog#retry_attempt_statuses column (network errors use 0,
which is outside the HTTP range) and exposed via the serializer along
with response_status for visibility into what was retried.

Bedrock Converse is intentionally left on SDK-managed retries to avoid
double-retrying request streams, and now records the failing status
code on the audit log.

Also adds a configurable service_tier param for Gemini (standard, flex,
priority) surfaced through the LLM model config.

---------

Co-authored-by: discourse-patch-triage[bot] <272280883+discourse-patch-triage[bot]@users.noreply.github.com>
2026-06-30 09:45:26 +10:00

111 lines
3 KiB
Ruby
Vendored

# frozen_string_literal: true
# special object that can be used to cancel completions and http requests
module DiscourseAi
module Completions
class CancelManager
attr_reader :cancelled
attr_reader :callbacks
def initialize
@cancelled = false
@cancel_event = Concurrent::Event.new
@callbacks = Concurrent::Array.new
@mutex = Mutex.new
@monitor_thread = nil
end
def monitor_thread
@mutex.synchronize { @monitor_thread }
end
def start_monitor(delay: 0.5, &block)
@mutex.synchronize do
raise "Already monitoring" if @monitor_thread
raise "Expected a block" if !block
db = RailsMultisite::ConnectionManagement.current_db
@stop_monitor = false
@monitor_thread =
Thread.new do
loop do
done = false
@mutex.synchronize { done = true if @stop_monitor }
break if done
sleep delay
@mutex.synchronize { done = true if @stop_monitor }
@mutex.synchronize { done = true if cancelled? }
break if done
should_cancel = false
RailsMultisite::ConnectionManagement.with_connection(db) do
should_cancel = block.call
end
@mutex.synchronize { cancel! if should_cancel }
break if cancelled?
end
ensure
@mutex.synchronize { @monitor_thread = nil }
end
end
end
def stop_monitor
monitor_thread = nil
@mutex.synchronize { monitor_thread = @monitor_thread }
if monitor_thread
@mutex.synchronize { @stop_monitor = true }
# so we do not deadlock
monitor_thread.wakeup
monitor_thread.join(2)
# should not happen
if monitor_thread.alive?
Rails.logger.warn("DiscourseAI: CancelManager monitor thread did not stop in time")
monitor_thread.kill if monitor_thread.alive?
end
@monitor_thread = nil
end
end
def cancelled?
@cancelled
end
def add_callback(cb)
@callbacks << cb
end
def wait_for_cancel(timeout)
@cancel_event.wait(timeout)
end
def remove_callback(cb)
@callbacks.delete(cb)
end
def cancel!
@cancelled = true
@cancel_event.set
monitor_thread = @monitor_thread
if monitor_thread && monitor_thread != Thread.current
monitor_thread.wakeup
monitor_thread.join(2)
if monitor_thread.alive?
Rails.logger.warn("DiscourseAI: CancelManager monitor thread did not stop in time")
monitor_thread.kill if monitor_thread.alive?
end
end
@callbacks.each do |cb|
cb.call
rescue StandardError
# ignore cause this may have already been cancelled
end
end
end
end
end