detached f981a2c

refactor(system): add retry logic and logging to timezone detection

commit
f981a2c911582804181acba76ad878255c235a47
author
bnema · · 2025-10-27 22:38 UTC
committer
bnema · · 2025-10-27 20:11 UTC
parent
9700fec
signature
unsigned
5 files · +93 -37
M internal/config/config.go +2 -6
@@ -49,11 +49,6 @@ const (
4949 KernelLinuxCachyOS = "linux-cachyos"
5050 )
5151
52-// AUR helpers
53-const (
54- AURHelperParu = "paru"
55- AURHelperYay = "yay"
56-)
5752
5853 // System paths
5954 const (
@@ -96,7 +91,7 @@ const (
9691 const (
9792 LocaleDefault = "en_US.UTF-8"
9893 LocaleDefaultGen = "en_US.UTF-8 UTF-8"
99- TimezoneDefault = "UTC"
94+ TimezoneDefault = ""
10095 KeymapDefault = "us"
10196 )
10297
@@ -160,6 +155,7 @@ const (
160155 PathMntEtcPacmanConf = "/mnt/etc/pacman.conf"
161156 )
162157
158+
163159 // PostInstall paths
164160 const (
165161 PathMntBootLogo = "/mnt/boot/arch-logo.png"
@@ -49,11 +49,6 @@ const (
49 KernelLinuxCachyOS = "linux-cachyos"49 KernelLinuxCachyOS = "linux-cachyos"
50 )50 )
51 51
52-// AUR helpers
53-const (
54- AURHelperParu = "paru"
55- AURHelperYay = "yay"
56-)
57 52
58 // System paths53 // System paths
59 const (54 const (
@@ -96,7 +91,7 @@ const (
96 const (91 const (
97 LocaleDefault = "en_US.UTF-8"92 LocaleDefault = "en_US.UTF-8"
98 LocaleDefaultGen = "en_US.UTF-8 UTF-8"93 LocaleDefaultGen = "en_US.UTF-8 UTF-8"
99- TimezoneDefault = "UTC"94+ TimezoneDefault = ""
100 KeymapDefault = "us"95 KeymapDefault = "us"
101 )96 )
102 97
@@ -160,6 +155,7 @@ const (
160 PathMntEtcPacmanConf = "/mnt/etc/pacman.conf"155 PathMntEtcPacmanConf = "/mnt/etc/pacman.conf"
161 )156 )
162 157
158+
163 // PostInstall paths159 // PostInstall paths
164 const (160 const (
165 PathMntBootLogo = "/mnt/boot/arch-logo.png"161 PathMntBootLogo = "/mnt/boot/arch-logo.png"
A internal/system/system.go +17 -0
@@ -0,0 +1,17 @@
1+package system
2+
3+import (
4+ "log/slog"
5+)
6+
7+// System provides system-level operations with logging support
8+type System struct {
9+ log *slog.Logger
10+}
11+
12+// NewSystem creates a new System instance with the provided logger
13+func NewSystem(log *slog.Logger) *System {
14+ return &System{
15+ log: log,
16+ }
17+}
@@ -0,0 +1,17 @@
1+package system
2+
3+import (
4+ "log/slog"
5+)
6+
7+// System provides system-level operations with logging support
8+type System struct {
9+ log *slog.Logger
10+}
11+
12+// NewSystem creates a new System instance with the provided logger
13+func NewSystem(log *slog.Logger) *System {
14+ return &System{
15+ log: log,
16+ }
17+}
M internal/system/timezone.go +65 -25
@@ -1,6 +1,7 @@
11 package system
22
33 import (
4+ "fmt"
45 "io"
56 "net/http"
67 "strings"
@@ -8,44 +9,83 @@ import (
89 )
910
1011 const (
11- timezoneAPIURL = "https://ipapi.co/timezone/"
12- timezoneAPITimeout = 5 * time.Second
12+ timezoneAPIURL = "https://ipapi.co/timezone/"
13+ timezoneAPITimeout = 5 * time.Second
14+ timezoneRetryAttempt = 3
15+ timezoneRetryDelay = 1 * time.Second
1316 )
1417
15-// DetectTimezone fetches timezone from ipapi.co API
18+// DetectTimezone fetches timezone from ipapi.co API with retry logic
19+// Attempts up to 3 times with exponential backoff before falling back
1620 // Returns detected timezone string or empty string on failure
17-func DetectTimezone() string {
18- client := &http.Client{
19- Timeout: timezoneAPITimeout,
20- }
21+// Logs details to the system logger
22+func (s *System) DetectTimezone() string {
23+ var lastErr error
2124
22- resp, err := client.Get(timezoneAPIURL)
23- switch {
24- case err != nil:
25- return ""
26- }
27- defer resp.Body.Close()
25+ for attempt := 1; attempt <= timezoneRetryAttempt; attempt++ {
26+ if attempt > 1 {
27+ // Log retry attempt
28+ s.log.Debug("Timezone detection retry",
29+ "attempt", attempt,
30+ "max_attempts", timezoneRetryAttempt,
31+ "delay", timezoneRetryDelay.String())
32+ time.Sleep(timezoneRetryDelay)
33+ }
2834
29- switch resp.StatusCode {
30- case http.StatusOK:
35+ client := &http.Client{
36+ Timeout: timezoneAPITimeout,
37+ }
38+
39+ resp, err := client.Get(timezoneAPIURL)
40+ if err != nil {
41+ lastErr = fmt.Errorf("http request failed: %w", err)
42+ s.log.Debug("Timezone detection failed",
43+ "attempt", attempt,
44+ "error", lastErr)
45+ continue
46+ }
47+ defer resp.Body.Close()
48+
49+ // Check HTTP status code
50+ if resp.StatusCode != http.StatusOK {
51+ lastErr = fmt.Errorf("API returned status %d", resp.StatusCode)
52+ s.log.Debug("Timezone detection failed",
53+ "attempt", attempt,
54+ "error", lastErr)
55+ continue
56+ }
57+
58+ // Read response body
3159 body, err := io.ReadAll(resp.Body)
32- switch {
33- case err != nil:
34- return ""
60+ if err != nil {
61+ lastErr = fmt.Errorf("failed to read response: %w", err)
62+ s.log.Debug("Timezone detection failed",
63+ "attempt", attempt,
64+ "error", lastErr)
65+ continue
3566 }
3667
3768 timezone := strings.TrimSpace(string(body))
3869
3970 // Validate timezone format (should be like "America/New_York")
40- switch {
41- case strings.Contains(timezone, "/"):
42- return timezone
43- default:
44- return ""
71+ if !strings.Contains(timezone, "/") {
72+ lastErr = fmt.Errorf("invalid timezone format: %q (expected format like America/New_York)", timezone)
73+ s.log.Debug("Timezone detection failed",
74+ "attempt", attempt,
75+ "error", lastErr)
76+ continue
4577 }
46- default:
47- return ""
78+
79+ // Success - log and return
80+ s.log.Info("Timezone detected successfully", "timezone", timezone)
81+ return timezone
4882 }
83+
84+ // All retries exhausted
85+ s.log.Warn("Timezone detection failed after all retries",
86+ "attempts", timezoneRetryAttempt,
87+ "last_error", lastErr)
88+ return ""
4989 }
5090
5191 // ValidateTimezone checks if a timezone string is valid
@@ -1,6 +1,7 @@
1 package system1 package system
2 2
3 import (3 import (
4+ "fmt"
4 "io"5 "io"
5 "net/http"6 "net/http"
6 "strings"7 "strings"
@@ -8,44 +9,83 @@ import (
8 )9 )
9 10
10 const (11 const (
11- timezoneAPIURL = "https://ipapi.co/timezone/"12+ timezoneAPIURL = "https://ipapi.co/timezone/"
12- timezoneAPITimeout = 5 * time.Second13+ timezoneAPITimeout = 5 * time.Second
14+ timezoneRetryAttempt = 3
15+ timezoneRetryDelay = 1 * time.Second
13 )16 )
14 17
15-// DetectTimezone fetches timezone from ipapi.co API18+// DetectTimezone fetches timezone from ipapi.co API with retry logic
19+// Attempts up to 3 times with exponential backoff before falling back
16 // Returns detected timezone string or empty string on failure20 // Returns detected timezone string or empty string on failure
17-func DetectTimezone() string {21+// Logs details to the system logger
18- client := &http.Client{22+func (s *System) DetectTimezone() string {
19- Timeout: timezoneAPITimeout,23+ var lastErr error
20- }
21 24
22- resp, err := client.Get(timezoneAPIURL)25+ for attempt := 1; attempt <= timezoneRetryAttempt; attempt++ {
23- switch {26+ if attempt > 1 {
24- case err != nil:27+ // Log retry attempt
25- return ""28+ s.log.Debug("Timezone detection retry",
26- }29+ "attempt", attempt,
27- defer resp.Body.Close()30+ "max_attempts", timezoneRetryAttempt,
31+ "delay", timezoneRetryDelay.String())
32+ time.Sleep(timezoneRetryDelay)
33+ }
28 34
29- switch resp.StatusCode {35+ client := &http.Client{
30- case http.StatusOK:36+ Timeout: timezoneAPITimeout,
37+ }
38+
39+ resp, err := client.Get(timezoneAPIURL)
40+ if err != nil {
41+ lastErr = fmt.Errorf("http request failed: %w", err)
42+ s.log.Debug("Timezone detection failed",
43+ "attempt", attempt,
44+ "error", lastErr)
45+ continue
46+ }
47+ defer resp.Body.Close()
48+
49+ // Check HTTP status code
50+ if resp.StatusCode != http.StatusOK {
51+ lastErr = fmt.Errorf("API returned status %d", resp.StatusCode)
52+ s.log.Debug("Timezone detection failed",
53+ "attempt", attempt,
54+ "error", lastErr)
55+ continue
56+ }
57+
58+ // Read response body
31 body, err := io.ReadAll(resp.Body)59 body, err := io.ReadAll(resp.Body)
32- switch {60+ if err != nil {
33- case err != nil:61+ lastErr = fmt.Errorf("failed to read response: %w", err)
34- return ""62+ s.log.Debug("Timezone detection failed",
63+ "attempt", attempt,
64+ "error", lastErr)
65+ continue
35 }66 }
36 67
37 timezone := strings.TrimSpace(string(body))68 timezone := strings.TrimSpace(string(body))
38 69
39 // Validate timezone format (should be like "America/New_York")70 // Validate timezone format (should be like "America/New_York")
40- switch {71+ if !strings.Contains(timezone, "/") {
41- case strings.Contains(timezone, "/"):72+ lastErr = fmt.Errorf("invalid timezone format: %q (expected format like America/New_York)", timezone)
42- return timezone73+ s.log.Debug("Timezone detection failed",
43- default:74+ "attempt", attempt,
44- return ""75+ "error", lastErr)
76+ continue
45 }77 }
46- default:78+
47- return ""79+ // Success - log and return
80+ s.log.Info("Timezone detected successfully", "timezone", timezone)
81+ return timezone
48 }82 }
83+
84+ // All retries exhausted
85+ s.log.Warn("Timezone detection failed after all retries",
86+ "attempts", timezoneRetryAttempt,
87+ "last_error", lastErr)
88+ return ""
49 }89 }
50 90
51 // ValidateTimezone checks if a timezone string is valid91 // ValidateTimezone checks if a timezone string is valid
M internal/ui/installer_forms.go +5 -4
@@ -4,6 +4,7 @@ import (
44 "fmt"
55
66 "github.com/bnema/archup/internal/config"
7+ "github.com/bnema/archup/internal/phases"
78 "github.com/bnema/archup/internal/system"
89 "github.com/bnema/archup/internal/ui/components"
910 "github.com/bnema/archup/internal/validation"
@@ -11,10 +12,10 @@ import (
1112 )
1213
1314 // CreatePreflightForm creates the initial configuration form with all user identity fields
14-func CreatePreflightForm(cfg *config.Config, fb *components.FormBuilder) *huh.Form {
15+func CreatePreflightForm(cfg *config.Config, fb *components.FormBuilder, sys *system.System) *huh.Form {
1516
1617 // Auto-detect timezone from API, fallback to default if detection fails
17- detectedTimezone := system.DetectTimezone()
18+ detectedTimezone := sys.DetectTimezone()
1819 switch {
1920 case detectedTimezone != "":
2021 cfg.Timezone = detectedTimezone
@@ -140,8 +141,8 @@ func CreateOptionsForm(cfg *config.Config, fb *components.FormBuilder) *huh.Form
140141 // Page 2: Repository and package management options
141142 aurOptions := []huh.Option[string]{
142143 huh.NewOption("None", ""),
143- components.CreateOption("Paru", DescAURHelperParu, config.AURHelperParu),
144- components.CreateOption("Yay", DescAURHelperYay, config.AURHelperYay),
144+ components.CreateOption("Paru", DescAURHelperParu, phases.AURHelperParu),
145+ components.CreateOption("Yay", DescAURHelperYay, phases.AURHelperYay),
145146 }
146147
147148 packageManagementGroup := huh.NewGroup(
@@ -4,6 +4,7 @@ import (
4 "fmt"4 "fmt"
5 5
6 "github.com/bnema/archup/internal/config"6 "github.com/bnema/archup/internal/config"
7+ "github.com/bnema/archup/internal/phases"
7 "github.com/bnema/archup/internal/system"8 "github.com/bnema/archup/internal/system"
8 "github.com/bnema/archup/internal/ui/components"9 "github.com/bnema/archup/internal/ui/components"
9 "github.com/bnema/archup/internal/validation"10 "github.com/bnema/archup/internal/validation"
@@ -11,10 +12,10 @@ import (
11 )12 )
12 13
13 // CreatePreflightForm creates the initial configuration form with all user identity fields14 // CreatePreflightForm creates the initial configuration form with all user identity fields
14-func CreatePreflightForm(cfg *config.Config, fb *components.FormBuilder) *huh.Form {15+func CreatePreflightForm(cfg *config.Config, fb *components.FormBuilder, sys *system.System) *huh.Form {
15 16
16 // Auto-detect timezone from API, fallback to default if detection fails17 // Auto-detect timezone from API, fallback to default if detection fails
17- detectedTimezone := system.DetectTimezone()18+ detectedTimezone := sys.DetectTimezone()
18 switch {19 switch {
19 case detectedTimezone != "":20 case detectedTimezone != "":
20 cfg.Timezone = detectedTimezone21 cfg.Timezone = detectedTimezone
@@ -140,8 +141,8 @@ func CreateOptionsForm(cfg *config.Config, fb *components.FormBuilder) *huh.Form
140 // Page 2: Repository and package management options141 // Page 2: Repository and package management options
141 aurOptions := []huh.Option[string]{142 aurOptions := []huh.Option[string]{
142 huh.NewOption("None", ""),143 huh.NewOption("None", ""),
143- components.CreateOption("Paru", DescAURHelperParu, config.AURHelperParu),144+ components.CreateOption("Paru", DescAURHelperParu, phases.AURHelperParu),
144- components.CreateOption("Yay", DescAURHelperYay, config.AURHelperYay),145+ components.CreateOption("Yay", DescAURHelperYay, phases.AURHelperYay),
145 }146 }
146 147
147 packageManagementGroup := huh.NewGroup(148 packageManagementGroup := huh.NewGroup(
M internal/ui/model.go +4 -2
@@ -42,6 +42,7 @@ type Model struct {
4242 formBuilder *components.FormBuilder
4343 output *components.OutputViewer
4444 logger *logger.Logger
45+ system *system.System
4546 spinner spinner.Model
4647 version string
4748 width int
@@ -66,6 +67,7 @@ func NewModel(orchestrator *phases.Orchestrator, cfg *config.Config, log *logger
6667 config: cfg,
6768 formBuilder: components.NewFormBuilder(false, 80), // Initial width, will update on WindowSizeMsg
6869 logger: log,
70+ system: system.NewSystem(log.Slog()),
6971 spinner: s,
7072 version: version,
7173 width: 80,
@@ -103,7 +105,7 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
103105 case StateShowLogo:
104106 m.logger.Info("Moving to preflight form")
105107 m.state = StatePreflightForm
106- m.currentForm = CreatePreflightForm(m.config, m.formBuilder)
108+ m.currentForm = CreatePreflightForm(m.config, m.formBuilder, m.system)
107109 return m, m.currentForm.Init()
108110
109111 case StateConfirmation:
@@ -117,7 +119,7 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
117119 switch m.state {
118120 case StateDiskForm:
119121 m.state = StatePreflightForm
120- m.currentForm = CreatePreflightForm(m.config, m.formBuilder)
122+ m.currentForm = CreatePreflightForm(m.config, m.formBuilder, m.system)
121123 return m, m.currentForm.Init()
122124
123125 case StateOptionsForm:
@@ -42,6 +42,7 @@ type Model struct {
42 formBuilder *components.FormBuilder42 formBuilder *components.FormBuilder
43 output *components.OutputViewer43 output *components.OutputViewer
44 logger *logger.Logger44 logger *logger.Logger
45+ system *system.System
45 spinner spinner.Model46 spinner spinner.Model
46 version string47 version string
47 width int48 width int
@@ -66,6 +67,7 @@ func NewModel(orchestrator *phases.Orchestrator, cfg *config.Config, log *logger
66 config: cfg,67 config: cfg,
67 formBuilder: components.NewFormBuilder(false, 80), // Initial width, will update on WindowSizeMsg68 formBuilder: components.NewFormBuilder(false, 80), // Initial width, will update on WindowSizeMsg
68 logger: log,69 logger: log,
70+ system: system.NewSystem(log.Slog()),
69 spinner: s,71 spinner: s,
70 version: version,72 version: version,
71 width: 80,73 width: 80,
@@ -103,7 +105,7 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
103 case StateShowLogo:105 case StateShowLogo:
104 m.logger.Info("Moving to preflight form")106 m.logger.Info("Moving to preflight form")
105 m.state = StatePreflightForm107 m.state = StatePreflightForm
106- m.currentForm = CreatePreflightForm(m.config, m.formBuilder)108+ m.currentForm = CreatePreflightForm(m.config, m.formBuilder, m.system)
107 return m, m.currentForm.Init()109 return m, m.currentForm.Init()
108 110
109 case StateConfirmation:111 case StateConfirmation:
@@ -117,7 +119,7 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
117 switch m.state {119 switch m.state {
118 case StateDiskForm:120 case StateDiskForm:
119 m.state = StatePreflightForm121 m.state = StatePreflightForm
120- m.currentForm = CreatePreflightForm(m.config, m.formBuilder)122+ m.currentForm = CreatePreflightForm(m.config, m.formBuilder, m.system)
121 return m, m.currentForm.Init()123 return m, m.currentForm.Init()
122 124
123 case StateOptionsForm:125 case StateOptionsForm:

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 0

    ↑↓ moveenter openesc close