Enhance avatar handling by implementing download retries with backoff and adding timeout configuration

This commit is contained in:
Leon Bösche
2026-01-31 17:48:30 +01:00
parent cf71b3c495
commit 6085409bad
3 changed files with 157 additions and 52 deletions

View File

@@ -409,10 +409,75 @@ class _HomePageState extends State<HomePage> with TickerProviderStateMixin {
if (!url.contains('token=') && state.token.isNotEmpty) {
url = "$url&token=${state.token}";
}
return CircleAvatar(
radius: 20,
backgroundImage: NetworkImage(url),
backgroundColor: Colors.transparent,
// Show default avatar while image downloads and display a progress ring
return SizedBox(
width: 44,
height: 44,
child: Stack(
alignment: Alignment.center,
children: [
// Default placeholder visible under the image/progress
CircleAvatar(
radius: 20,
backgroundColor: isSelected
? highlightColor
: defaultColor,
child: Icon(icon, color: AppTheme.primaryBackground),
),
// Network image with loading and error handling
ClipOval(
child: Image.network(
url,
width: 40,
height: 40,
fit: BoxFit.cover,
loadingBuilder: (context, child, loadingProgress) {
if (loadingProgress == null) return child;
final expected =
loadingProgress.expectedTotalBytes;
final loaded =
loadingProgress.cumulativeBytesLoaded;
double? value;
if (expected != null && expected > 0) {
value = loaded / expected;
}
return SizedBox(
width: 40,
height: 40,
child: Stack(
alignment: Alignment.center,
children: [
// transparent circle so placeholder remains visible
CircleAvatar(
radius: 20,
backgroundColor: Colors.transparent,
),
SizedBox(
width: 44,
height: 44,
child: CircularProgressIndicator(
value: value,
strokeWidth: 2.5,
valueColor:
AlwaysStoppedAnimation<Color>(
AppTheme.accentColor,
),
backgroundColor: AppTheme.secondaryText
.withValues(alpha: 0.12),
),
),
],
),
);
},
errorBuilder: (context, error, stackTrace) {
// keep placeholder visible on error
return const SizedBox.shrink();
},
),
),
],
),
);
}
return CircleAvatar(

View File

@@ -3,41 +3,46 @@ package config
import (
"log"
"os"
"strconv"
)
type Config struct {
ServerAddr string
DatabaseURL string
OIDCIssuerURL string
OIDCRedirectURL string
OIDCClientID string
OIDCClientSecret string
JWTSecret string
NextcloudURL string
NextcloudUser string
NextcloudPass string
NextcloudBase string
AllowedOrigins string
AvatarCacheDir string
ServerAddr string
DatabaseURL string
OIDCIssuerURL string
OIDCRedirectURL string
OIDCClientID string
OIDCClientSecret string
JWTSecret string
NextcloudURL string
NextcloudUser string
NextcloudPass string
NextcloudBase string
AllowedOrigins string
AvatarCacheDir string
AvatarDownloadTimeoutSeconds int
AvatarDownloadRetries int
}
func Load() *Config {
cfg := &Config{
ServerAddr: getEnv("SERVER_ADDR", ":8080"),
DatabaseURL: os.Getenv("DATABASE_URL"),
OIDCIssuerURL: os.Getenv("OIDC_ISSUER_URL"),
OIDCRedirectURL: os.Getenv("OIDC_REDIRECT_URL"),
OIDCClientID: os.Getenv("OIDC_CLIENT_ID"),
OIDCClientSecret: os.Getenv("OIDC_CLIENT_SECRET"),
JWTSecret: os.Getenv("JWT_SECRET"),
NextcloudURL: os.Getenv("NEXTCLOUD_URL"),
NextcloudUser: os.Getenv("NEXTCLOUD_USER"),
NextcloudPass: os.Getenv("NEXTCLOUD_PASSWORD"),
NextcloudBase: getEnv("NEXTCLOUD_BASEPATH", "/"),
AllowedOrigins: getEnv("ALLOWED_ORIGINS", "https://b0esche.cloud,https://www.b0esche.cloud,https://*.b0esche.cloud,http://localhost:8080"),
AvatarCacheDir: getEnv("AVATAR_CACHE_DIR", "/var/cache/b0esche/avatars"),
ServerAddr: getEnv("SERVER_ADDR", ":8080"),
DatabaseURL: os.Getenv("DATABASE_URL"),
OIDCIssuerURL: os.Getenv("OIDC_ISSUER_URL"),
OIDCRedirectURL: os.Getenv("OIDC_REDIRECT_URL"),
OIDCClientID: os.Getenv("OIDC_CLIENT_ID"),
OIDCClientSecret: os.Getenv("OIDC_CLIENT_SECRET"),
JWTSecret: os.Getenv("JWT_SECRET"),
NextcloudURL: os.Getenv("NEXTCLOUD_URL"),
NextcloudUser: os.Getenv("NEXTCLOUD_USER"),
NextcloudPass: os.Getenv("NEXTCLOUD_PASSWORD"),
NextcloudBase: getEnv("NEXTCLOUD_BASEPATH", "/"),
AllowedOrigins: getEnv("ALLOWED_ORIGINS", "https://b0esche.cloud,https://www.b0esche.cloud,https://*.b0esche.cloud,http://localhost:8080"),
AvatarCacheDir: getEnv("AVATAR_CACHE_DIR", "/var/cache/b0esche/avatars"),
AvatarDownloadTimeoutSeconds: getEnvInt("AVATAR_DOWNLOAD_TIMEOUT_SECONDS", 10),
AvatarDownloadRetries: getEnvInt("AVATAR_DOWNLOAD_RETRIES", 2),
}
log.Printf("[CONFIG] Nextcloud URL: %q, User: %q, BasePath: %q, AvatarCacheDir: %q\n", cfg.NextcloudURL, cfg.NextcloudUser, cfg.NextcloudBase, cfg.AvatarCacheDir)
log.Printf("[CONFIG] Nextcloud URL: %q, User: %q, BasePath: %q, AvatarCacheDir: %q, AvatarDownloadTimeoutSeconds: %d, AvatarDownloadRetries: %d\n", cfg.NextcloudURL, cfg.NextcloudUser, cfg.NextcloudBase, cfg.AvatarCacheDir, cfg.AvatarDownloadTimeoutSeconds, cfg.AvatarDownloadRetries)
return cfg
}
@@ -47,3 +52,12 @@ func getEnv(key, defaultVal string) string {
}
return defaultVal
}
func getEnvInt(key string, defaultVal int) int {
if val := os.Getenv(key); val != "" {
if i, err := strconv.Atoi(val); err == nil {
return i
}
}
return defaultVal
}

View File

@@ -6,7 +6,6 @@ import (
"context"
"database/sql"
"encoding/json"
stderrors "errors"
"fmt"
"io"
"log"
@@ -4301,7 +4300,7 @@ func getUserAvatarHandler(w http.ResponseWriter, r *http.Request, db *database.D
}
}
// Download from WebDAV
// Download from WebDAV with retries and backoff
client := storage.NewWebDAVClient(cfg)
if client == nil {
errors.WriteError(w, errors.CodeInternal, "WebDAV client not configured", http.StatusInternalServerError)
@@ -4309,31 +4308,58 @@ func getUserAvatarHandler(w http.ResponseWriter, r *http.Request, db *database.D
}
remotePath := strings.TrimPrefix(*avatarURL, client.BaseURL+"/")
// Use a short timeout to avoid hanging behind proxies; return 404 if WebDAV is slow or times out
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
// Configure retry & timeout from config
timeoutSeconds := cfg.AvatarDownloadTimeoutSeconds
if timeoutSeconds <= 0 {
timeoutSeconds = 10
}
retries := cfg.AvatarDownloadRetries
if retries < 0 {
retries = 0
}
attempts := retries + 1 // total attempts
resp, err := client.Download(ctx, remotePath, "")
if err != nil {
// If download timed out or gateway returned 504, try to serve cached avatar
if strings.Contains(err.Error(), "504") || stderrors.Is(err, context.DeadlineExceeded) {
v = r.URL.Query().Get("v")
if data, ct, cerr := readAvatarCache(cfg, userID.String(), v); cerr == nil {
w.Header().Set("Content-Type", ct)
w.Header().Set("Cache-Control", "public, max-age=300")
w.WriteHeader(http.StatusOK)
w.Write(data)
fmt.Printf("[INFO] Served avatar for user=%s from cache due to WebDAV timeout (v=%s)\n", userID.String(), v)
return
}
var resp *http.Response
var dlErr error
for attempt := 0; attempt < attempts; attempt++ {
ctx, cancel := context.WithTimeout(r.Context(), time.Duration(timeoutSeconds)*time.Second)
resp, dlErr = client.Download(ctx, remotePath, "")
cancel()
if dlErr == nil {
break
}
fmt.Printf("[ERROR] Avatar download timeout for remotePath=%s: %v\n", remotePath, err)
// If 404 on remote storage, the avatar file truly doesn't exist
if dlErr != nil && strings.Contains(dlErr.Error(), "404") {
fmt.Printf("[ERROR] Avatar not found on storage for remotePath=%s: %v\n", remotePath, dlErr)
w.WriteHeader(http.StatusNotFound)
return
}
errors.LogError(r, err, "Failed to download avatar")
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
// Log and apply backoff for retryable errors
fmt.Printf("[WARN] Avatar download attempt %d/%d failed for remotePath=%s: %v\n", attempt+1, attempts, remotePath, dlErr)
if attempt < attempts-1 {
// exponential backoff: 500ms, 1s, 2s, ...
backoffMs := 500 * (1 << attempt)
time.Sleep(time.Duration(backoffMs) * time.Millisecond)
}
}
if dlErr != nil || resp == nil {
// Try to serve the latest cached avatar (if any) as a graceful fallback
if data, ct, cerr := readAvatarCache(cfg, userID.String(), ""); cerr == nil {
w.Header().Set("Content-Type", ct)
w.Header().Set("Cache-Control", "public, max-age=300")
w.WriteHeader(http.StatusOK)
w.Write(data)
fmt.Printf("[INFO] Served latest cached avatar for user=%s after WebDAV failures\n", userID.String())
return
}
// No cached avatar available; surface an explicit error (502) so clients can retry
fmt.Printf("[ERROR] Avatar download failed after %d attempts for remotePath=%s: %v\n", attempts, remotePath, dlErr)
w.Header().Set("Retry-After", "30")
errors.WriteError(w, errors.CodeInternal, "Upstream storage unavailable", http.StatusBadGateway)
return
}
defer resp.Body.Close()