From ea1f9dd41e1fe71f035ff759d8262aff7fd3108a Mon Sep 17 00:00:00 2001 From: Francis Belanger Date: Thu, 25 Jun 2026 09:33:34 -0400 Subject: [PATCH] feat: add retry logic and health check for server connection --- lua/opencode/config_file.lua | 11 ++- lua/opencode/git_review.lua | 2 - lua/opencode/opencode_server.lua | 31 +++++++ lua/opencode/promise.lua | 36 ++++++++ lua/opencode/server_job.lua | 86 +++++++++----------- tests/unit/persist_state_spec.lua | 3 + tests/unit/server_job_spec.lua | 4 + tests/unit/services_session_runtime_spec.lua | 3 + 8 files changed, 125 insertions(+), 51 deletions(-) diff --git a/lua/opencode/config_file.lua b/lua/opencode/config_file.lua index 257c4b82..ecfd46ba 100644 --- a/lua/opencode/config_file.lua +++ b/lua/opencode/config_file.lua @@ -10,13 +10,16 @@ local M = { M.get_opencode_config = Promise.async(function() if not M.config_promise then local state = require('opencode.state') - M.config_promise = state.api_client:get_config() + M.config_promise = Promise.retry(function() + return state.api_client:get_config() + end, 3, 500) end local ok, result = pcall(function() return M.config_promise:await() end) if not ok then + M.config_promise = nil vim.notify('Error fetching Opencode config: ' .. vim.inspect(result), vim.log.levels.ERROR) return nil end @@ -28,12 +31,15 @@ end) M.get_opencode_project = Promise.async(function() if not M.project_promise then local state = require('opencode.state') - M.project_promise = state.api_client:get_current_project() + M.project_promise = Promise.retry(function() + return state.api_client:get_current_project() + end, 3, 500) end local ok, result = pcall(function() return M.project_promise:await() end) if not ok then + M.project_promise = nil vim.notify('Error fetching Opencode project: ' .. vim.inspect(result), vim.log.levels.ERROR) return nil end @@ -77,6 +83,7 @@ function M.get_opencode_providers() end local wrapped = M.providers_promise:catch(function(err) vim.notify('Error fetching Opencode providers: ' .. vim.inspect(err), vim.log.levels.ERROR) + M.providers_promise = nil return nil end) if not _providers_render_callback then diff --git a/lua/opencode/git_review.lua b/lua/opencode/git_review.lua index 739f81ed..cf0e04af 100644 --- a/lua/opencode/git_review.lua +++ b/lua/opencode/git_review.lua @@ -286,9 +286,7 @@ M.revert_selected_file = require_git_project(function(ref) end) M.revert_all = require_git_project(function(ref) - vim.print('⭕ ❱ git_review.lua:288 ❱ ƒ(anonymous) ❱ ref =', ref) M.__current_ref = ref or M.get_first_snapshot() - vim.print('⭕ ❱ git_review.lua:289 ❱ ƒ(M.__current_ref) ❱ M.__current_ref =', M.__current_ref) local files = get_changed_files() diff --git a/lua/opencode/opencode_server.lua b/lua/opencode/opencode_server.lua index aacb7187..0016421b 100644 --- a/lua/opencode/opencode_server.lua +++ b/lua/opencode/opencode_server.lua @@ -86,6 +86,37 @@ function OpencodeServer:is_running() return self.job.pid ~= nil end +---Perform a health check on a server URL. +---@param url string The full health endpoint URL +---@param timeout_ms number Timeout in milliseconds +---@return Promise +function OpencodeServer.health_check(url, timeout_ms) + local health_promise = Promise.new() + curl.request({ + url = url, + method = 'GET', + timeout = timeout_ms or 2000, + proxy = '', + callback = function(response) + health_promise:resolve(response ~= nil and response.status >= 200 and response.status < 300) + end, + on_error = function(_err) + health_promise:resolve(false) + end, + }) + return health_promise +end + +---Check if the server is reachable via its health endpoint. +---@return Promise +function OpencodeServer:check_health() + if not self.url then + return Promise.new():resolve(false) + end + local health_url = self.url:gsub('/$', '') .. '/global/health' + return OpencodeServer.health_check(health_url, 2000) +end + local function kill_process(pid, signal, desc) local log = require('opencode.log') local ok, err = pcall(vim.uv.kill, pid, signal) diff --git a/lua/opencode/promise.lua b/lua/opencode/promise.lua index 20ed2e05..315ae706 100644 --- a/lua/opencode/promise.lua +++ b/lua/opencode/promise.lua @@ -418,4 +418,40 @@ function Promise.all(promises) end) end +---Create a promise that resolves after a delay. +---@param ms number Delay in milliseconds +---@return Promise +function Promise.delay(ms) + local delay = Promise.new() + vim.defer_fn(function() + pcall(delay.resolve, delay, true) + end, ms) + return delay +end + +---Retry a promise-returning operation on failure. +---@generic T +---@param factory fun(): Promise Creates a fresh promise per attempt +---@param max_retries number Total attempts (1 = no retry) +---@param delay_ms number Delay between retries in milliseconds +---@return Promise +function Promise.retry(factory, max_retries, delay_ms) + return Promise.spawn(function() + local last_err + for i = 1, max_retries do + local ok, result = pcall(function() + return factory():await() + end) + if ok then + return result + end + last_err = result + if i < max_retries then + Promise.delay(delay_ms):await() + end + end + return Promise.new():reject(last_err) + end) +end + return Promise diff --git a/lua/opencode/server_job.lua b/lua/opencode/server_job.lua index 66fb5a83..fc711308 100644 --- a/lua/opencode/server_job.lua +++ b/lua/opencode/server_job.lua @@ -20,38 +20,20 @@ end --- @param timeout number --- @return Promise local function try_custom_server(base_url, timeout) - local promise = Promise.new() local health_url = base_url .. '/global/health' log.debug('try_custom_server: checking health at %s', health_url) - curl.request({ - url = health_url, - method = 'GET', - timeout = timeout * 1000, - proxy = '', -- Disable proxy for health check - callback = function(response) - if response and response.status >= 200 and response.status < 300 then - local success, health_data = pcall(vim.json.decode, response.body) - if success and health_data then - log.debug('try_custom_server: health check passed') - promise:resolve(base_url) - return - end - end - - local err_msg = - string.format('Health check failed at %s (status: %d)', health_url, response and response.status or 0) - log.debug('try_custom_server: %s', err_msg) - promise:reject(err_msg) - end, - on_error = function(err) - log.debug('try_custom_server: error connecting to %s: %s', health_url, vim.inspect(err)) - promise:reject(err) - end, - }) + return opencode_server.health_check(health_url, timeout * 1000):and_then(function(healthy) + if healthy then + log.debug('try_custom_server: health check passed') + return base_url + end - return promise + local err_msg = string.format('Health check failed at %s', health_url) + log.debug('try_custom_server: %s', err_msg) + return Promise.new():reject(err_msg) + end) end --- @param response {status: integer, body: string} @@ -185,15 +167,9 @@ local function resolve_port() return existing or math.random(1024, 65535) end ---- Ensure the opencode server is running, starting it if necessary. ---- @return Promise -function M.ensure_server() +local function _start_server() local promise = Promise.new() - if state.opencode_server and state.opencode_server:is_running() then - return promise:resolve(state.opencode_server) - end - local custom_url = config.server.url if not custom_url then log.debug('ensure_server: server.url not configured, spawning local server') @@ -218,20 +194,36 @@ function M.ensure_server() return promise end -local function retry_connect(base_url, timeout, max_retries, on_success, on_failure) - local function attempt(retry_count) - vim.defer_fn(function() - try_custom_server(base_url, timeout):and_then(on_success):catch(function(err) - if retry_count < max_retries then - attempt(retry_count + 1) - else - log.error('try_connect_to_custom_server: exhausted %d retries: %s', max_retries, vim.inspect(err)) - on_failure(err) - end - end) - end, retry_count * (config.server.retry_delay or 2000)) +--- Ensure the opencode server is running, starting it if necessary. +--- @return Promise +function M.ensure_server() + if state.opencode_server and state.opencode_server:is_running() then + return state.opencode_server:check_health():and_then(function(healthy) + if healthy then + return state.opencode_server + end + log.warn('ensure_server: cached server unhealthy, reconnecting') + state.jobs.clear_server() + return _start_server() + end) end - attempt(1) + + return _start_server() +end + +local function retry_connect(base_url, timeout, max_retries, on_success, on_failure) + local delay = config.server.retry_delay or 2000 + Promise.delay(delay) + :and_then(function() + return Promise.retry(function() + return try_custom_server(base_url, timeout) + end, max_retries, delay) + end) + :and_then(on_success) + :catch(function(err) + log.error('try_connect_to_custom_server: exhausted %d retries: %s', max_retries, vim.inspect(err)) + on_failure(err) + end) end local function spawn_and_retry(base_url, custom_port, custom_url, promise, timeout) diff --git a/tests/unit/persist_state_spec.lua b/tests/unit/persist_state_spec.lua index 5ee51d1e..3847eb12 100644 --- a/tests/unit/persist_state_spec.lua +++ b/tests/unit/persist_state_spec.lua @@ -197,6 +197,9 @@ describe('persist_state', function() is_running = function() return true end, + check_health = function() + return Promise.new():resolve(true) + end, spawn = function() end, shutdown = function() return Promise.new():resolve(true) diff --git a/tests/unit/server_job_spec.lua b/tests/unit/server_job_spec.lua index c40f6318..76083a10 100644 --- a/tests/unit/server_job_spec.lua +++ b/tests/unit/server_job_spec.lua @@ -1,4 +1,5 @@ local server_job = require('opencode.server_job') +local Promise = require('opencode.promise') local curl = require('opencode.curl') local assert = require('luassert') @@ -93,6 +94,9 @@ describe('server_job', function() end) end, shutdown = function() end, + check_health = function() + return Promise.new():resolve(true) + end, } opencode_server.new = function() return fake diff --git a/tests/unit/services_session_runtime_spec.lua b/tests/unit/services_session_runtime_spec.lua index 11f3aa33..694f4ab2 100644 --- a/tests/unit/services_session_runtime_spec.lua +++ b/tests/unit/services_session_runtime_spec.lua @@ -85,6 +85,9 @@ describe('opencode.services.session_runtime', function() end, shutdown = function() end, url = 'http://127.0.0.1:4000', + check_health = function() + return Promise.new():resolve(true) + end, }) end)