feat: each provider can have its own timeout
CI / Checks (pull_request) Successful in 41s
PR title / Conventional title (pull_request) Successful in 1s

providers.azure.timeout, providers.databricks.timeout and
providers.repos.timeout override providers.timeout for that provider's
requests; --timeout still sets them all. lazysecrets share, which reads
a key and repo files, waits the longest of them.
This commit is contained in:
keb committed 2026-10-13 19:58:42 +02:00
1 parent 46c702a27c
commit be83a9d074
11 files changed
+106 -29

No files matched your search

+31 -14
View File
@@ -65,6 +65,8 @@ const (
type Options struct {
// Timeout bounds every provider call. Defaults to 30s.
Timeout time.Duration
// Timeouts override Timeout for the providers of a kind.
Timeouts map[provider.Kind]time.Duration
// Icons shows Nerd Font glyphs next to backends.
Icons bool
// Clipboard defaults to the system clipboard, or OSC52 over SSH.
@@ -146,7 +148,7 @@ type Clipboard interface {
type Model struct {
providers []provider.Provider
byID map[string]provider.Provider
timeout time.Duration
timeout timeouts
keys keys.Map
st styles.Styles
@@ -378,7 +380,7 @@ func New(providers []provider.Provider, opts Options) Model {
m := Model{
providers: providers,
byID: map[string]provider.Provider{},
timeout: opts.Timeout,
timeout: timeouts{def: opts.Timeout, byKind: opts.Timeouts},
keys: keys.Default(),
st: styles.Default(),
spin: spinner.New(spinner.WithSpinner(spinner.Dot)),
@@ -1246,7 +1248,7 @@ func (m *Model) showVersions(o provider.Object, key string) tea.Cmd {
}
m.secrets.SetLoading()
p := m.providerFor(o.Container.Source)
ctx, req := m.begin(slotObjects)
ctx, req := m.begin(slotObjects, o.Container.Source.Kind)
return func() tea.Msg {
items, err := p.Versions(ctx, o)
for i := range items {
@@ -1276,7 +1278,7 @@ func (m *Model) countVersions(o provider.Object) tea.Cmd {
}
}
p := m.providerFor(o.Container.Source)
ctx, req := m.begin(slotVersions)
ctx, req := m.begin(slotVersions, o.Container.Source.Kind)
return m.settled(ctx, func() tea.Msg {
items, err := p.Versions(ctx, o)
for i := range items {
@@ -1427,11 +1429,26 @@ func (m *Model) autoLoad(msg sourcesMsg) tea.Cmd {
return m.crawlStart()
}
// begin starts a cancellable request for pane p, cancelling any in flight.
func (m *Model) begin(p slot) (context.Context, int) {
// timeouts bound each provider request: its kind's, else the default.
type timeouts struct {
def time.Duration
byKind map[provider.Kind]time.Duration
}
// of is how long a request to a provider of kind k may take.
func (t timeouts) of(k provider.Kind) time.Duration {
if d, ok := t.byKind[k]; ok {
return d
}
return t.def
}
// begin starts a cancellable request for pane p to a provider of kind k,
// cancelling any in flight.
func (m *Model) begin(p slot, k provider.Kind) (context.Context, int) {
m.cancelReq(p)
m.seq++
ctx, cancel := context.WithTimeout(context.Background(), m.timeout)
ctx, cancel := context.WithTimeout(context.Background(), m.timeout.of(k))
m.reqID[p], m.cancel[p] = m.seq, cancel
return ctx, m.seq
}
@@ -1485,7 +1502,7 @@ func (m Model) sourcesCmd() tea.Cmd {
gen := m.sourcesGen
var cmds []tea.Cmd
for _, p := range m.providers {
ctx, cancel := context.WithTimeout(context.Background(), m.timeout)
ctx, cancel := context.WithTimeout(context.Background(), m.timeout.of(p.Kind()))
cmds = append(cmds, func() tea.Msg {
defer cancel()
s, err := p.Sources(ctx)
@@ -1496,7 +1513,7 @@ func (m Model) sourcesCmd() tea.Cmd {
})
// The source it started in, quickly, on its own.
if c, ok := p.(provider.Current); ok {
ctx, cancel := context.WithTimeout(context.Background(), m.timeout)
ctx, cancel := context.WithTimeout(context.Background(), m.timeout.of(p.Kind()))
cmds = append(cmds, func() tea.Msg {
defer cancel()
src, ok := c.Current(ctx)
@@ -1513,7 +1530,7 @@ func (m Model) sourcesCmd() tea.Cmd {
func (m *Model) loadContainers(src provider.Source, key string) tea.Cmd {
p := m.providerFor(src)
ctx, req := m.begin(slotContainers)
ctx, req := m.begin(slotContainers, src.Kind)
return m.settled(ctx, func() tea.Msg {
items, err := p.Containers(ctx, src)
// Carry the source as given, with its provider ID, so later
@@ -1532,7 +1549,7 @@ func (m *Model) loadContainers(src provider.Source, key string) tea.Cmd {
func (m *Model) loadObjects(c provider.Container, t provider.ObjectType, key string) tea.Cmd {
cands := m.candidates(c.Source)
known := len(cands) == 1 && cands[0].ID() == c.Source.Provider
ctx, req := m.begin(slotObjects)
ctx, req := m.begin(slotObjects, c.Source.Kind)
return m.settled(ctx, func() tea.Msg {
var errs []error
for _, p := range cands {
@@ -1560,7 +1577,7 @@ func (m *Model) loadObjects(c provider.Container, t provider.ObjectType, key str
func (m *Model) fetchValue(s provider.Object) tea.Cmd {
p := m.providerFor(s.Container.Source)
ctx, req := m.begin(slotValue)
ctx, req := m.begin(slotValue, s.Container.Source.Kind)
m.detail.SetLoading()
return func() tea.Msg {
v, err := p.Value(ctx, s)
@@ -1579,7 +1596,7 @@ func (m *Model) describe(o provider.Object) tea.Cmd {
m.detail.SetDescribing()
}
p := m.providerFor(o.Container.Source)
ctx, req := m.begin(slotDescribe)
ctx, req := m.begin(slotDescribe, o.Container.Source.Kind)
return m.settled(ctx, func() tea.Msg {
obj, err := p.Describe(ctx, o)
return describeMsg{req: req, key: key, obj: obj, err: err}
@@ -1607,7 +1624,7 @@ func (m *Model) copyValue() tea.Cmd {
return m.copyText(o.Name, v.Reveal(), true)
}
p := m.providerFor(o.Container.Source)
ctx, req := m.begin(slotCopy)
ctx, req := m.begin(slotCopy, o.Container.Source.Kind)
m.flash = flash{text: "copying " + o.Name + "…"}
return func() tea.Msg {
v, err := p.Value(ctx, o)
+1 -1
View File
@@ -165,7 +165,7 @@ func (m *Model) runCommand(c commands.Command, rendered string, obj *provider.Ob
o := *obj
p := m.providerFor(o.Container.Source)
m.flash = flash{text: "fetching " + o.Name + " for " + c.Description + "…"}
timeout := m.timeout
timeout := m.timeout.of(o.Container.Source.Kind)
m.usedObject(o)
return func() tea.Msg {
ctx, cancel := context.WithTimeout(context.Background(), timeout)
+1 -1
View File
@@ -196,7 +196,7 @@ func (m *Model) loadDetails(c provider.Container) tea.Cmd {
}
}
m.details[key] = detailsState{loading: true}
ctx, req := m.begin(slotDetails)
ctx, req := m.begin(slotDetails, c.Source.Kind)
return m.settled(ctx, func() tea.Msg {
attrs, err := d.Details(ctx, c)
if ctx.Err() != nil {
+1 -1
View File
@@ -158,7 +158,7 @@ func (m *Model) copyFromRef(r ref.Ref) tea.Cmd {
m.flash = flash{text: "copy: no account or profile for " + describeRef(r), isErr: true}
return nil
}
ctx, req := m.begin(slotCopy)
ctx, req := m.begin(slotCopy, r.Kind)
m.flash = flash{text: "copying " + r.Name + "…"}
return func() tea.Msg {
var errs []error
+2 -1
View File
@@ -295,10 +295,10 @@ func (m *Model) crawlNext() tea.Cmd {
}
func (m *Model) crawlCmd(job crawlJob) tea.Cmd {
timeout := m.timeout
if job.src != nil {
p := m.providerFor(*job.src)
src := *job.src
timeout := m.timeout.of(src.Kind)
return func() tea.Msg {
ctx, cancel := contextWithTimeout(timeout)
defer cancel()
@@ -311,6 +311,7 @@ func (m *Model) crawlCmd(job crawlJob) tea.Cmd {
}
c := *job.container
p := m.providerFor(c.Source)
timeout := m.timeout.of(c.Source.Kind)
return func() tea.Msg {
ctx, cancel := contextWithTimeout(timeout)
defer cancel()
+3 -3
View File
@@ -59,7 +59,7 @@ func (m *Model) shareFiles(src provider.Source, cs []provider.Container) tea.Cmd
m.flash = warning("these files can't be shared")
return nil
}
general, timeout := m.shareKey, m.timeout
general, timeout := m.shareKey, m.timeout.of(src.Kind)
m.flash = flash{text: "reading the files…"}
return func() tea.Msg {
ctx, cancel := context.WithTimeout(context.Background(), timeout)
@@ -216,7 +216,7 @@ func (m *Model) handleSealed(msg sealedMsg) tea.Cmd {
func (m *Model) keyReader() func(ref.Ref) (provider.SecretValue, string, error) {
providers, st, timeout := m.providers, m.store, m.timeout
return func(key ref.Ref) (provider.SecretValue, string, error) {
ctx, cancel := context.WithTimeout(context.Background(), timeout)
ctx, cancel := context.WithTimeout(context.Background(), timeout.of(key.Kind))
defer cancel()
k := key
k.Version = ""
@@ -258,7 +258,7 @@ func (m *Model) offerShare(r ref.Ref) tea.Cmd {
// openShare reads the key, decrypts the files and finds the repo's clone.
func (m *Model) openShare(s share.Sealed) tea.Cmd {
read := m.keyReader()
providers, timeout := m.providers, m.timeout
providers, timeout := m.providers, m.timeout.of(provider.KindRepo)
m.flash = flash{text: "decrypting with " + s.Key.Name + "…"}
return func() tea.Msg {
msg := shareOpenedMsg{sealed: s}
+25
View File
@@ -0,0 +1,25 @@
package app
import (
"strings"
"testing"
"time"
"git.kebcloud.no/keb/lazysecrets/internal/provider"
"git.kebcloud.no/keb/lazysecrets/internal/provider/fake"
)
// A provider's own timeout applies to it alone.
func TestTimeoutPerProvider(t *testing.T) {
t.Parallel()
d := newDriverOpts(t, Options{Timeout: 5 * time.Second, Timeouts: map[provider.Kind]time.Duration{provider.KindAzure: 10 * time.Millisecond}},
fake.Azure(300*time.Millisecond), fake.Databricks(300*time.Millisecond))
scr := d.screen()
if !strings.Contains(scr, "✗ Azure: context deadline exceeded") || !strings.Contains(scr, "Databricks (…)") ||
strings.Contains(scr, "Databricks: context deadline") {
t.Errorf("want Azure timed out and Databricks still loading:\n%s", scr)
}
if got := (timeouts{def: time.Minute}).of(provider.KindRepo); got != time.Minute {
t.Errorf("no override: %s, want the default", got)
}
}