Enhance avatar handling by implementing download retries with backoff and adding timeout configuration
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user