From f4ed73544116282c5e666a97325d1d252bb75b0f Mon Sep 17 00:00:00 2001
From: Pascal Bleser
Date: Mon, 20 Oct 2025 16:34:47 +0200
Subject: [PATCH] groupware: add the Retry-After header in responses when the
session cannot be retrieved
---
.../pkg/groupware/groupware_framework.go | 52 +++++++++++--------
.../pkg/groupware/groupware_session.go | 24 +++++++--
2 files changed, 51 insertions(+), 25 deletions(-)
diff --git a/services/groupware/pkg/groupware/groupware_framework.go b/services/groupware/pkg/groupware/groupware_framework.go
index 33334257d4..23e00800d1 100644
--- a/services/groupware/pkg/groupware/groupware_framework.go
+++ b/services/groupware/pkg/groupware/groupware_framework.go
@@ -446,16 +446,17 @@ func (g *Groupware) ServeSSE(w http.ResponseWriter, r *http.Request) {
}
// Provide a JMAP Session for the
-func (g *Groupware) session(user user, _ *log.Logger) (jmap.Session, bool, *GroupwareError) {
+func (g *Groupware) session(user user, _ *log.Logger) (jmap.Session, bool, *GroupwareError, time.Time) {
s := g.sessionCache.Get(user.GetUsername())
if s != nil {
if s.Success() {
- return s.Get(), true, nil
+ return s.Get(), true, nil, s.Until()
} else {
- return jmap.Session{}, false, s.Error()
+ return jmap.Session{}, false, s.Error(), s.Until()
}
}
- return jmap.Session{}, false, nil
+ // not sure this should happen:
+ return jmap.Session{}, false, nil, time.Time{}
}
func (g *Groupware) log(error *Error) {
@@ -491,18 +492,25 @@ func (g *Groupware) log(error *Error) {
l.Msg(error.Title)
}
-func (g *Groupware) serveError(w http.ResponseWriter, r *http.Request, error *Error) {
+func (g *Groupware) serveError(w http.ResponseWriter, r *http.Request, error *Error, retryAfter time.Time) {
if error == nil {
return
}
g.log(error)
w.Header().Add("Content-Type", ContentTypeJsonApi)
+ if !retryAfter.IsZero() {
+ // https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Retry-After
+ // either as an absolute timestamp:
+ // w.Header().Add("Retry-After", retryAfter.UTC().Format(time.RFC1123))
+ // or as a delay in seconds:
+ w.Header().Add("Retry-After", fmt.Sprintf("%.0f", time.Until(retryAfter).Seconds()))
+ }
render.Status(r, error.NumStatus)
w.WriteHeader(error.NumStatus)
render.Render(w, r, errorResponses(*error))
}
-func (g *Groupware) withSession(w http.ResponseWriter, r *http.Request, handler func(r Request) Response) (Response, bool) {
+func (g *Groupware) withSession(w http.ResponseWriter, r *http.Request, handler func(r Request) Response) (Response, time.Time, bool) {
ctx := r.Context()
sl := g.logger.SubloggerWithRequestID(ctx)
logger := &sl
@@ -510,24 +518,24 @@ func (g *Groupware) withSession(w http.ResponseWriter, r *http.Request, handler
user, err := g.userProvider.GetUser(r, ctx, logger)
if err != nil {
g.metrics.AuthenticationFailureCounter.Inc()
- g.serveError(w, r, apiError(errorId(r, ctx), ErrorInvalidAuthentication))
- return Response{}, false
+ g.serveError(w, r, apiError(errorId(r, ctx), ErrorInvalidAuthentication), time.Time{})
+ return Response{}, time.Time{}, false
}
if user == nil {
g.metrics.AuthenticationFailureCounter.Inc()
- g.serveError(w, r, apiError(errorId(r, ctx), ErrorMissingAuthentication))
- return Response{}, false
+ g.serveError(w, r, apiError(errorId(r, ctx), ErrorMissingAuthentication), time.Time{})
+ return Response{}, time.Time{}, false
}
logger = log.From(logger.With().Str(logUserId, log.SafeString(user.GetId())))
- session, ok, gwerr := g.session(user, logger)
+ session, ok, gwerr, retryAfter := g.session(user, logger)
if gwerr != nil {
g.metrics.SessionFailureCounter.Inc()
errorId := errorId(r, ctx)
logger.Error().Str("code", gwerr.Code).Str("error", gwerr.Title).Str("detail", gwerr.Detail).Str(logErrorId, errorId).Msg("failed to determine JMAP session")
- g.serveError(w, r, apiError(errorId, *gwerr))
- return Response{}, false
+ g.serveError(w, r, apiError(errorId, *gwerr), retryAfter)
+ return Response{}, retryAfter, false
}
if !ok {
// no session = authentication failed
@@ -535,8 +543,8 @@ func (g *Groupware) withSession(w http.ResponseWriter, r *http.Request, handler
errorId := errorId(r, ctx)
logger.Error().Str(logErrorId, errorId).Msg("could not authenticate, failed to find Session")
gwerr = &ErrorInvalidAuthentication
- g.serveError(w, r, apiError(errorId, *gwerr))
- return Response{}, false
+ g.serveError(w, r, apiError(errorId, *gwerr), retryAfter)
+ return Response{}, retryAfter, false
}
decoratedLogger := decorateLogger(logger, session)
@@ -550,7 +558,7 @@ func (g *Groupware) withSession(w http.ResponseWriter, r *http.Request, handler
}
response := handler(req)
- return response, true
+ return response, time.Time{}, true
}
func (g *Groupware) sendResponse(w http.ResponseWriter, r *http.Request, response Response) {
@@ -606,7 +614,7 @@ func (g *Groupware) sendResponse(w http.ResponseWriter, r *http.Request, respons
}
func (g *Groupware) respond(w http.ResponseWriter, r *http.Request, handler func(r Request) Response) {
- response, ok := g.withSession(w, r, handler)
+ response, _, ok := g.withSession(w, r, handler)
if !ok {
return
}
@@ -620,21 +628,21 @@ func (g *Groupware) stream(w http.ResponseWriter, r *http.Request, handler func(
user, err := g.userProvider.GetUser(r, ctx, logger)
if err != nil {
- g.serveError(w, r, apiError(errorId(r, ctx), ErrorInvalidAuthentication))
+ g.serveError(w, r, apiError(errorId(r, ctx), ErrorInvalidAuthentication), time.Time{})
return
}
if user == nil {
- g.serveError(w, r, apiError(errorId(r, ctx), ErrorMissingAuthentication))
+ g.serveError(w, r, apiError(errorId(r, ctx), ErrorMissingAuthentication), time.Time{})
return
}
logger = log.From(logger.With().Str(logUserId, log.SafeString(user.GetId())))
- session, ok, gwerr := g.session(user, logger)
+ session, ok, gwerr, retryAfter := g.session(user, logger)
if gwerr != nil {
errorId := errorId(r, ctx)
logger.Error().Str("code", gwerr.Code).Str("error", gwerr.Title).Str("detail", gwerr.Detail).Str(logErrorId, errorId).Msg("failed to determine JMAP session")
- g.serveError(w, r, apiError(errorId, *gwerr))
+ g.serveError(w, r, apiError(errorId, *gwerr), retryAfter)
return
}
if !ok {
@@ -642,7 +650,7 @@ func (g *Groupware) stream(w http.ResponseWriter, r *http.Request, handler func(
errorId := errorId(r, ctx)
logger.Error().Str(logErrorId, errorId).Msg("could not authenticate, failed to find Session")
gwerr = &ErrorInvalidAuthentication
- g.serveError(w, r, apiError(errorId, *gwerr))
+ g.serveError(w, r, apiError(errorId, *gwerr), retryAfter)
return
}
diff --git a/services/groupware/pkg/groupware/groupware_session.go b/services/groupware/pkg/groupware/groupware_session.go
index a85748f55b..9b1f0eab9b 100644
--- a/services/groupware/pkg/groupware/groupware_session.go
+++ b/services/groupware/pkg/groupware/groupware_session.go
@@ -38,12 +38,16 @@ type cachedSession interface {
Error() *GroupwareError
// The timestamp of when this cached session information was obtained, regardless of success or failure.
Since() time.Time
+ // The timestamp of when this cached session information will be invalidated, regardless of success or failure.
+ Until() time.Time
}
// An implementation of a cachedSession that succeeded.
type succeededSession struct {
// Timestamp of when this succeededSession was created.
since time.Time
+ // Until when the session will be cached
+ until time.Time
// The JMAP Session itself.
session jmap.Session
}
@@ -62,11 +66,16 @@ func (s succeededSession) Error() *GroupwareError {
func (s succeededSession) Since() time.Time {
return s.since
}
+func (s succeededSession) Until() time.Time {
+ return s.until
+}
// An implementation of a cachedSession that failed.
type failedSession struct {
// Timestamp of when this failedSession was created.
since time.Time
+ // Until when the failure will be cached, without re-attempting to retrieve the Session.
+ until time.Time
// The error that caused the Session acquisition to fail.
err *GroupwareError
}
@@ -85,6 +94,9 @@ func (s failedSession) Error() *GroupwareError {
func (s failedSession) Since() time.Time {
return s.since
}
+func (s failedSession) Until() time.Time {
+ return s.until
+}
// Implements the ttlcache.Loader interface, by loading JMAP Sessions for users
// using the jmap.Client.
@@ -104,15 +116,21 @@ func (l *sessionCacheLoader) Load(c *ttlcache.Cache[sessionCacheKey, cachedSessi
sessionUrl, gwerr := l.sessionUrlProvider(username)
if gwerr != nil {
l.logger.Warn().Str("username", username).Str("code", gwerr.Code).Msgf("failed to determine session URL for '%v'", key)
- return c.Set(key, failedSession{since: time.Now(), err: gwerr}, l.errorTtl)
+ now := time.Now()
+ until := now.Add(l.errorTtl)
+ return c.Set(key, failedSession{since: now, until: until, err: gwerr}, l.errorTtl)
}
session, jerr := l.sessionSupplier(sessionUrl, username, l.logger)
if jerr != nil {
l.logger.Warn().Str("username", username).Err(jerr).Msgf("failed to create session for '%v'", key)
- return c.Set(key, failedSession{since: time.Now(), err: groupwareErrorFromJmap(jerr)}, l.errorTtl)
+ now := time.Now()
+ until := now.Add(l.errorTtl)
+ return c.Set(key, failedSession{since: now, until: until, err: groupwareErrorFromJmap(jerr)}, l.errorTtl)
} else {
l.logger.Debug().Str("username", username).Msgf("successfully created session for '%v'", key)
- return c.Set(key, succeededSession{since: time.Now(), session: session}, ttlcache.DefaultTTL) // use the TTL configured on the Cache
+ now := time.Now()
+ until := now.Add(ttlcache.DefaultTTL)
+ return c.Set(key, succeededSession{since: now, until: until, session: session}, ttlcache.DefaultTTL) // use the TTL configured on the Cache
}
}