@@ -23,6 +23,8 @@ const (
2323 DefaultDownloadBaseURL = "https://csgclaw.opencsg.com/codex-cli/latest"
2424 EnvDownloadBaseURL = "CSGCLAW_CODEX_DOWNLOAD_BASE_URL"
2525 defaultInstallTimeout = 15 * time .Minute
26+ defaultInstallAttempts = 3
27+ installRetryBaseDelay = 100 * time .Millisecond
2628 maxDownloadSize = int64 (512 << 20 )
2729 maxBinarySize = int64 (512 << 20 )
2830 maxArchiveExpandedSize = int64 (768 << 20 )
@@ -80,6 +82,31 @@ type downloadPlatform struct {
8082 archiveBinary string
8183}
8284
85+ type retryableInstallError struct {
86+ err error
87+ }
88+
89+ func (e * retryableInstallError ) Error () string {
90+ return e .err .Error ()
91+ }
92+
93+ func (e * retryableInstallError ) Unwrap () error {
94+ return e .err
95+ }
96+
97+ type downloadReader struct {
98+ reader io.Reader
99+ err error
100+ }
101+
102+ func (r * downloadReader ) Read (buffer []byte ) (int , error ) {
103+ read , err := r .reader .Read (buffer )
104+ if err != nil && ! errors .Is (err , io .EOF ) {
105+ r .err = err
106+ }
107+ return read , err
108+ }
109+
83110func NewInstaller (opts InstallerOptions ) * Installer {
84111 goos := strings .ToLower (strings .TrimSpace (opts .GOOS ))
85112 if goos == "" {
@@ -98,7 +125,7 @@ func NewInstaller(opts InstallerOptions) *Installer {
98125 }
99126 client := opts .HTTPClient
100127 if client == nil {
101- client = & http. Client { Timeout : defaultInstallTimeout }
128+ client = defaultHTTPClient ( goos )
102129 }
103130 baseURL := strings .TrimRight (strings .TrimSpace (opts .BaseURL ), "/" )
104131 if baseURL == "" {
@@ -117,6 +144,17 @@ func NewInstaller(opts InstallerOptions) *Installer {
117144 }
118145}
119146
147+ func defaultHTTPClient (goos string ) * http.Client {
148+ transport := http .DefaultTransport .(* http.Transport ).Clone ()
149+ if strings .EqualFold (strings .TrimSpace (goos ), "windows" ) {
150+ protocols := new (http.Protocols )
151+ protocols .SetHTTP1 (true )
152+ transport .Protocols = protocols
153+ transport .ForceAttemptHTTP2 = false
154+ }
155+ return & http.Client {Timeout : defaultInstallTimeout , Transport : transport }
156+ }
157+
120158func (i * Installer ) Status () InstallStatus {
121159 if i == nil {
122160 return InstallStatus {State : InstallStateFailed , Message : "Codex installer is not configured" }
@@ -199,7 +237,23 @@ func (i *Installer) install(ctx context.Context) (InstallStatus, error) {
199237 if err := os .MkdirAll (filepath .Dir (targetPath ), 0o755 ); err != nil {
200238 return InstallStatus {}, fmt .Errorf ("create Codex install directory: %w" , err )
201239 }
240+ for attempt := 1 ; attempt <= defaultInstallAttempts ; attempt ++ {
241+ status , err := i .installAttempt (ctx , platform , downloadURL , targetPath )
242+ if err == nil {
243+ return status , nil
244+ }
245+ var retryable * retryableInstallError
246+ if ! errors .As (err , & retryable ) || attempt == defaultInstallAttempts {
247+ return InstallStatus {}, err
248+ }
249+ if err := waitForInstallRetry (ctx , attempt ); err != nil {
250+ return InstallStatus {}, fmt .Errorf ("retry Codex CLI download: %w" , err )
251+ }
252+ }
253+ return InstallStatus {}, errors .New ("install Codex CLI: exhausted download attempts" )
254+ }
202255
256+ func (i * Installer ) installAttempt (ctx context.Context , platform downloadPlatform , downloadURL , targetPath string ) (InstallStatus , error ) {
203257 req , err := http .NewRequestWithContext (ctx , http .MethodGet , downloadURL , nil )
204258 if err != nil {
205259 return InstallStatus {}, fmt .Errorf ("create Codex download request: %w" , err )
@@ -208,16 +262,26 @@ func (i *Installer) install(ctx context.Context) (InstallStatus, error) {
208262 req .Header .Set ("User-Agent" , "csgclaw-codex-installer" )
209263 resp , err := i .httpClient .Do (req )
210264 if err != nil {
211- return InstallStatus {}, fmt .Errorf ("download Codex CLI: %w" , err )
265+ err = fmt .Errorf ("download Codex CLI: %w" , err )
266+ if ctx .Err () == nil {
267+ err = & retryableInstallError {err : err }
268+ }
269+ return InstallStatus {}, err
212270 }
213271 defer resp .Body .Close ()
214272 if resp .StatusCode != http .StatusOK {
215273 message , _ := io .ReadAll (io .LimitReader (resp .Body , 4096 ))
216274 detail := strings .TrimSpace (string (message ))
275+ var err error
217276 if detail != "" {
218- return InstallStatus {}, fmt .Errorf ("download Codex CLI: server returned %s: %s" , resp .Status , detail )
277+ err = fmt .Errorf ("download Codex CLI: server returned %s: %s" , resp .Status , detail )
278+ } else {
279+ err = fmt .Errorf ("download Codex CLI: server returned %s" , resp .Status )
280+ }
281+ if retryableHTTPStatus (resp .StatusCode ) {
282+ err = & retryableInstallError {err : err }
219283 }
220- return InstallStatus {}, fmt . Errorf ( "download Codex CLI: server returned %s" , resp . Status )
284+ return InstallStatus {}, err
221285 }
222286 if resp .ContentLength > maxDownloadSize {
223287 return InstallStatus {}, fmt .Errorf ("download Codex CLI: package size %d exceeds limit %d" , resp .ContentLength , maxDownloadSize )
@@ -235,13 +299,17 @@ func (i *Installer) install(ctx context.Context) (InstallStatus, error) {
235299 }
236300 }()
237301
238- limited := & io.LimitedReader {R : resp .Body , N : maxDownloadSize + 1 }
302+ body := & downloadReader {reader : resp .Body }
303+ limited := & io.LimitedReader {R : body , N : maxDownloadSize + 1 }
239304 if i .goos == "windows" {
240305 err = installWindowsBinary (temp , limited )
241306 } else {
242307 err = installUnixArchive (temp , limited , platform .archiveBinary )
243308 }
244309 if err != nil {
310+ if body .err != nil {
311+ err = & retryableInstallError {err : err }
312+ }
245313 return InstallStatus {}, err
246314 }
247315 if err := validatePlatformBinary (temp , i .goos , i .goarch ); err != nil {
@@ -271,6 +339,25 @@ func (i *Installer) install(ctx context.Context) (InstallStatus, error) {
271339 return InstallStatus {State : InstallStateInstalled , Installed : true , Path : installedPath }, nil
272340}
273341
342+ func retryableHTTPStatus (status int ) bool {
343+ return status == http .StatusRequestTimeout ||
344+ status == http .StatusTooEarly ||
345+ status == http .StatusTooManyRequests ||
346+ status >= http .StatusInternalServerError && status < 600
347+ }
348+
349+ func waitForInstallRetry (ctx context.Context , attempt int ) error {
350+ delay := installRetryBaseDelay << (attempt - 1 )
351+ timer := time .NewTimer (delay )
352+ defer timer .Stop ()
353+ select {
354+ case <- ctx .Done ():
355+ return ctx .Err ()
356+ case <- timer .C :
357+ return nil
358+ }
359+ }
360+
274361func installWindowsBinary (dst * os.File , src io.Reader ) error {
275362 written , err := io .Copy (dst , io .LimitReader (src , maxBinarySize + 1 ))
276363 if err != nil {
0 commit comments