Skip to content

Commit 61be47e

Browse files
committed
feat: add retry logic and health check for server connection
1 parent 6df3fd2 commit 61be47e

7 files changed

Lines changed: 122 additions & 51 deletions

File tree

lua/opencode/config_file.lua

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,13 +10,16 @@ local M = {
1010
M.get_opencode_config = Promise.async(function()
1111
if not M.config_promise then
1212
local state = require('opencode.state')
13-
M.config_promise = state.api_client:get_config()
13+
M.config_promise = Promise.retry(function()
14+
return state.api_client:get_config()
15+
end, 3, 500)
1416
end
1517
local ok, result = pcall(function()
1618
return M.config_promise:await()
1719
end)
1820

1921
if not ok then
22+
M.config_promise = nil
2023
vim.notify('Error fetching Opencode config: ' .. vim.inspect(result), vim.log.levels.ERROR)
2124
return nil
2225
end
@@ -28,12 +31,15 @@ end)
2831
M.get_opencode_project = Promise.async(function()
2932
if not M.project_promise then
3033
local state = require('opencode.state')
31-
M.project_promise = state.api_client:get_current_project()
34+
M.project_promise = Promise.retry(function()
35+
return state.api_client:get_current_project()
36+
end, 3, 500)
3237
end
3338
local ok, result = pcall(function()
3439
return M.project_promise:await()
3540
end)
3641
if not ok then
42+
M.project_promise = nil
3743
vim.notify('Error fetching Opencode project: ' .. vim.inspect(result), vim.log.levels.ERROR)
3844
return nil
3945
end
@@ -77,6 +83,7 @@ function M.get_opencode_providers()
7783
end
7884
local wrapped = M.providers_promise:catch(function(err)
7985
vim.notify('Error fetching Opencode providers: ' .. vim.inspect(err), vim.log.levels.ERROR)
86+
M.providers_promise = nil
8087
return nil
8188
end)
8289
if not _providers_render_callback then

lua/opencode/git_review.lua

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -286,9 +286,7 @@ M.revert_selected_file = require_git_project(function(ref)
286286
end)
287287

288288
M.revert_all = require_git_project(function(ref)
289-
vim.print('⭕ ❱ git_review.lua:288 ❱ ƒ(anonymous) ❱ ref =', ref)
290289
M.__current_ref = ref or M.get_first_snapshot()
291-
vim.print('⭕ ❱ git_review.lua:289 ❱ ƒ(M.__current_ref) ❱ M.__current_ref =', M.__current_ref)
292290

293291
local files = get_changed_files()
294292

lua/opencode/opencode_server.lua

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,37 @@ function OpencodeServer:is_running()
8686
return self.job.pid ~= nil
8787
end
8888

89+
---Perform a health check on a server URL.
90+
---@param url string The full health endpoint URL
91+
---@param timeout_ms number Timeout in milliseconds
92+
---@return Promise<boolean>
93+
function OpencodeServer.health_check(url, timeout_ms)
94+
local health_promise = Promise.new()
95+
curl.request({
96+
url = url,
97+
method = 'GET',
98+
timeout = timeout_ms or 2000,
99+
proxy = '',
100+
callback = function(response)
101+
health_promise:resolve(response ~= nil and response.status >= 200 and response.status < 300)
102+
end,
103+
on_error = function(_err)
104+
health_promise:resolve(false)
105+
end,
106+
})
107+
return health_promise
108+
end
109+
110+
---Check if the server is reachable via its health endpoint.
111+
---@return Promise<boolean>
112+
function OpencodeServer:check_health()
113+
if not self.url then
114+
return Promise.new():resolve(false)
115+
end
116+
local health_url = self.url:gsub('/$', '') .. '/global/health'
117+
return OpencodeServer.health_check(health_url, 2000)
118+
end
119+
89120
local function kill_process(pid, signal, desc)
90121
local log = require('opencode.log')
91122
local ok, err = pcall(vim.uv.kill, pid, signal)

lua/opencode/promise.lua

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -418,4 +418,40 @@ function Promise.all(promises)
418418
end)
419419
end
420420

421+
---Create a promise that resolves after a delay.
422+
---@param ms number Delay in milliseconds
423+
---@return Promise<boolean>
424+
function Promise.delay(ms)
425+
local delay = Promise.new()
426+
vim.defer_fn(function()
427+
pcall(delay.resolve, delay, true)
428+
end, ms)
429+
return delay
430+
end
431+
432+
---Retry a promise-returning operation on failure.
433+
---@generic T
434+
---@param factory fun(): Promise<T> Creates a fresh promise per attempt
435+
---@param max_retries number Total attempts (1 = no retry)
436+
---@param delay_ms number Delay between retries in milliseconds
437+
---@return Promise<T>
438+
function Promise.retry(factory, max_retries, delay_ms)
439+
return Promise.spawn(function()
440+
local last_err
441+
for i = 1, max_retries do
442+
local ok, result = pcall(function()
443+
return factory():await()
444+
end)
445+
if ok then
446+
return result
447+
end
448+
last_err = result
449+
if i < max_retries then
450+
Promise.delay(delay_ms):await()
451+
end
452+
end
453+
return Promise.new():reject(last_err)
454+
end)
455+
end
456+
421457
return Promise

lua/opencode/server_job.lua

Lines changed: 39 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -20,38 +20,20 @@ end
2020
--- @param timeout number
2121
--- @return Promise<string|nil>
2222
local function try_custom_server(base_url, timeout)
23-
local promise = Promise.new()
2423
local health_url = base_url .. '/global/health'
2524

2625
log.debug('try_custom_server: checking health at %s', health_url)
2726

28-
curl.request({
29-
url = health_url,
30-
method = 'GET',
31-
timeout = timeout * 1000,
32-
proxy = '', -- Disable proxy for health check
33-
callback = function(response)
34-
if response and response.status >= 200 and response.status < 300 then
35-
local success, health_data = pcall(vim.json.decode, response.body)
36-
if success and health_data then
37-
log.debug('try_custom_server: health check passed')
38-
promise:resolve(base_url)
39-
return
40-
end
41-
end
42-
43-
local err_msg =
44-
string.format('Health check failed at %s (status: %d)', health_url, response and response.status or 0)
45-
log.debug('try_custom_server: %s', err_msg)
46-
promise:reject(err_msg)
47-
end,
48-
on_error = function(err)
49-
log.debug('try_custom_server: error connecting to %s: %s', health_url, vim.inspect(err))
50-
promise:reject(err)
51-
end,
52-
})
27+
return opencode_server.health_check(health_url, timeout * 1000):and_then(function(healthy)
28+
if healthy then
29+
log.debug('try_custom_server: health check passed')
30+
return base_url
31+
end
5332

54-
return promise
33+
local err_msg = string.format('Health check failed at %s', health_url)
34+
log.debug('try_custom_server: %s', err_msg)
35+
return Promise.new():reject(err_msg)
36+
end)
5537
end
5638

5739
--- @param response {status: integer, body: string}
@@ -185,15 +167,9 @@ local function resolve_port()
185167
return existing or math.random(1024, 65535)
186168
end
187169

188-
--- Ensure the opencode server is running, starting it if necessary.
189-
--- @return Promise<OpencodeServer>
190-
function M.ensure_server()
170+
local function _start_server()
191171
local promise = Promise.new()
192172

193-
if state.opencode_server and state.opencode_server:is_running() then
194-
return promise:resolve(state.opencode_server)
195-
end
196-
197173
local custom_url = config.server.url
198174
if not custom_url then
199175
log.debug('ensure_server: server.url not configured, spawning local server')
@@ -218,20 +194,36 @@ function M.ensure_server()
218194
return promise
219195
end
220196

221-
local function retry_connect(base_url, timeout, max_retries, on_success, on_failure)
222-
local function attempt(retry_count)
223-
vim.defer_fn(function()
224-
try_custom_server(base_url, timeout):and_then(on_success):catch(function(err)
225-
if retry_count < max_retries then
226-
attempt(retry_count + 1)
227-
else
228-
log.error('try_connect_to_custom_server: exhausted %d retries: %s', max_retries, vim.inspect(err))
229-
on_failure(err)
230-
end
231-
end)
232-
end, retry_count * (config.server.retry_delay or 2000))
197+
--- Ensure the opencode server is running, starting it if necessary.
198+
--- @return Promise<OpencodeServer>
199+
function M.ensure_server()
200+
if state.opencode_server and state.opencode_server:is_running() then
201+
return state.opencode_server:check_health():and_then(function(healthy)
202+
if healthy then
203+
return state.opencode_server
204+
end
205+
log.warn('ensure_server: cached server unhealthy, reconnecting')
206+
state.jobs.clear_server()
207+
return _start_server()
208+
end)
233209
end
234-
attempt(1)
210+
211+
return _start_server()
212+
end
213+
214+
local function retry_connect(base_url, timeout, max_retries, on_success, on_failure)
215+
local delay = config.server.retry_delay or 2000
216+
Promise.delay(delay)
217+
:and_then(function()
218+
return Promise.retry(function()
219+
return try_custom_server(base_url, timeout)
220+
end, max_retries, delay)
221+
end)
222+
:and_then(on_success)
223+
:catch(function(err)
224+
log.error('try_connect_to_custom_server: exhausted %d retries: %s', max_retries, vim.inspect(err))
225+
on_failure(err)
226+
end)
235227
end
236228

237229
local function spawn_and_retry(base_url, custom_port, custom_url, promise, timeout)

tests/unit/server_job_spec.lua

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
local server_job = require('opencode.server_job')
2+
local Promise = require('opencode.promise')
23

34
local curl = require('opencode.curl')
45
local assert = require('luassert')
@@ -93,6 +94,9 @@ describe('server_job', function()
9394
end)
9495
end,
9596
shutdown = function() end,
97+
check_health = function()
98+
return Promise.new():resolve(true)
99+
end,
96100
}
97101
opencode_server.new = function()
98102
return fake

tests/unit/services_session_runtime_spec.lua

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,9 @@ describe('opencode.services.session_runtime', function()
8585
end,
8686
shutdown = function() end,
8787
url = 'http://127.0.0.1:4000',
88+
check_health = function()
89+
return Promise.new():resolve(true)
90+
end,
8891
})
8992
end)
9093

0 commit comments

Comments
 (0)