Skip to content

Commit 0d91a51

Browse files
nkottaryclaude
andauthored
Retry on request-interrupted watch failures (#68)
* Retry on request-interrupted watch failures Long-lived Pod watches can be interrupted by transient connection resets (idle LB/proxy timeouts, apiserver restarts, network blips), which OpenAPI.jl surfaces as InvocationException("request was interrupted"). k8s_retry_cond only retried ApiException with retryable status codes, so this case fell straight through to callers as an unrecoverable error. Treat it as retryable, matching the existing is_longpoll_timeout handling. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * Don't retry request-interrupted watches that were intentionally stopped @tanmaykm pointed out on #68 that closing the watch event channel is the documented way for a consumer to stop an in-progress watch (see the `watch(streamprocessor, ctx, watched, ...)` composition), and that this also surfaces as `is_request_interrupted` in the :downloads backend: the interrupt-watcher task force-interrupts the download on channel close, leaving `resp` nothing, which `exec()` reports identically to a genuine dropped connection. Thread the event/output stream through k8s_retry so k8s_retry_cond can tell the two cases apart: only treat is_request_interrupted as retryable while that channel is still open. A closed channel means the consumer asked to stop, so let it propagate instead of retrying. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
1 parent 1ac9ef0 commit 0d91a51

3 files changed

Lines changed: 28 additions & 13 deletions

File tree

Project.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ authors = ["JuliaHub Inc."]
44
keywords = ["kubernetes", "client"]
55
license = "MIT"
66
desc = "Julia Kubernetes Client"
7-
version = "0.7.10"
7+
version = "0.7.11"
88

99
[deps]
1010
Dates = "ade2ca70-3891-5945-98fb-dc099432e06a"

src/helpers.jl

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,18 +25,33 @@ OpenAPI status codes that can be retried.
2525
"""
2626
const k8s_retryable_codes = [0, 500, 501, 502, 503, 504]
2727

28-
function k8s_retry_cond(s, e, retryable_codes=k8s_retryable_codes)
28+
"""
29+
`stream`, if given, is the event channel a watch call streams into. Closing it is
30+
the documented way for a consumer to stop an in-progress watch (see `watch` in
31+
simpleapi.jl), and doing so also surfaces as `is_request_interrupted`. Only treat
32+
`is_request_interrupted` as retryable while that channel is still open, so an
33+
intentional stop isn't mistaken for a transient interruption and retried instead
34+
of being allowed to terminate.
35+
"""
36+
function k8s_retry_cond(s, e, retryable_codes=k8s_retryable_codes; stream::Union{Channel,Nothing}=nothing)
2937
if (e isa OpenAPI.Clients.ApiException) && (e.status in retryable_codes)
3038
return (s, true)
3139
end
40+
if OpenAPI.Clients.is_request_interrupted(e) && (stream === nothing || isopen(stream))
41+
return (s, true)
42+
end
3243
(s, false)
3344
end
3445

3546
"""
3647
Retry api call automatically (if `max_tries > 1`) on certain retryable failures.
3748
Backoff to use when retrying k8s APIs. The default minimum is 2 TPS.
49+
50+
`stream`, if given, is passed to `k8s_retry_cond` to distinguish an intentional
51+
watch-stop (channel closed by the consumer) from a transient interruption.
3852
"""
39-
k8s_retry(f; max_tries=1, tps=2) = retry(f, delays=k8s_delay(tps,max_tries), check=k8s_retry_cond)()
53+
k8s_retry(f; max_tries=1, tps=2, stream::Union{Channel,Nothing}=nothing) =
54+
retry(f, delays=k8s_delay(tps,max_tries), check=(s,e)->k8s_retry_cond(s,e; stream=stream))()
4055

4156
"""
4257
Build keyword arguments for OpenAPI.Clients.Client constructor.

src/simpleapi.jl

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,7 @@ function list(ctx::Union{KuberContext,KuberWatchContext}, O::Symbol, name::Strin
9797
end
9898

9999
# start watch and return the HTTP response object on completion
100-
result = k8s_retry(; max_tries=max_tries) do
100+
result = k8s_retry(; max_tries=max_tries, stream=eventstream) do
101101
check_api_response(apicall(apictx, eventstream, args...; watch=watch, resource_version=resource_version, kwargs...)...)
102102
end
103103

@@ -148,7 +148,7 @@ function list(ctx::Union{KuberContext,KuberWatchContext}, O::Symbol;
148148
end
149149

150150
# start watch and return the HTTP response object on completion
151-
result = k8s_retry(; max_tries=max_tries) do
151+
result = k8s_retry(; max_tries=max_tries, stream=eventstream) do
152152
check_api_response(apicall(apictx, eventstream, args...; watch=watch, resource_version=resource_version, kwargs...)...)
153153
end
154154

@@ -197,7 +197,7 @@ function get(ctx::Union{KuberContext,KuberWatchContext}, O::Symbol, name::String
197197
end
198198

199199
# start watch and return the HTTP response object on completion
200-
result = k8s_retry(; max_tries=max_tries) do
200+
result = k8s_retry(; max_tries=max_tries, stream=eventstream) do
201201
check_api_response(apicall(apictx, eventstream, args...; watch=watch, resource_version=resource_version, kwargs...)...)
202202
end
203203

@@ -252,7 +252,7 @@ function get(ctx::Union{KuberContext,KuberWatchContext}, O::Symbol;
252252
end
253253

254254
# start watch and return the HTTP response object on completion
255-
result = k8s_retry(; max_tries=max_tries) do
255+
result = k8s_retry(; max_tries=max_tries, stream=eventstream) do
256256
check_api_response(apicall(apictx, eventstream, args...; watch=watch, resource_version=resource_version, label_selector, kwargs...)...)
257257
end
258258

@@ -272,17 +272,17 @@ function watch(ctx::KuberContext, O::Symbol, outstream::Channel, name::String;
272272

273273
if allnamespaces
274274
apicall = apimodule(ctx).eval(Symbol("watch_$(_O_)_for_all_namespaces"))
275-
result = k8s_retry(; max_tries=max_tries) do
275+
result = k8s_retry(; max_tries=max_tries, stream=outstream) do
276276
check_api_response(apicall(apictx, outstream, name; kwargs...)...)
277277
end
278278
elseif namespaced
279279
apicall = apimodule(ctx).eval(Symbol("watch_namespaced_$(_O_)"))
280-
result = k8s_retry(; max_tries=max_tries) do
280+
result = k8s_retry(; max_tries=max_tries, stream=outstream) do
281281
check_api_response(apicall(apictx, outstream, name, namespace; kwargs...)...)
282282
end
283283
else
284284
apicall = apimodule(ctx).eval(Symbol("watch_$(_O_)"))
285-
result = k8s_retry(; max_tries=max_tries) do
285+
result = k8s_retry(; max_tries=max_tries, stream=outstream) do
286286
check_api_response(apicall(apictx, outstream, name; kwargs...)...)
287287
end
288288
end
@@ -303,17 +303,17 @@ function watch(ctx::KuberContext, O::Symbol, outstream::Channel;
303303

304304
if allnamespaces
305305
apicall = apimodule(ctx).eval(Symbol("watch_$(_O_)_for_all_namespaces"))
306-
result = k8s_retry(; max_tries=max_tries) do
306+
result = k8s_retry(; max_tries=max_tries, stream=outstream) do
307307
check_api_response(apicall(apictx, outstream; kwargs...)...)
308308
end
309309
elseif namespaced
310310
apicall = apimodule(ctx).eval(Symbol("watch_namespaced_$(_O_)"))
311-
result = k8s_retry(; max_tries=max_tries) do
311+
result = k8s_retry(; max_tries=max_tries, stream=outstream) do
312312
check_api_response(apicall(apictx, outstream, namespace; kwargs...)...)
313313
end
314314
else
315315
apicall = apimodule(ctx).eval(Symbol("watch_$(_O_)"))
316-
result = k8s_retry(; max_tries=max_tries) do
316+
result = k8s_retry(; max_tries=max_tries, stream=outstream) do
317317
check_api_response(apicall(apictx, outstream; kwargs...)...)
318318
end
319319
end

0 commit comments

Comments
 (0)