From 148da9350d584eb7ca810dda034ea8ceb691746b Mon Sep 17 00:00:00 2001 From: Nick Mills-Barrett Date: Mon, 19 Oct 2026 13:27:05 +0000 Subject: [PATCH 1/7] handlematrix: map Reddit failures to message statuses Matrix handlers returned raw or wrapped Reddit client errors, which became retriable statuses with no message, and user-caused rejections such as deleting a chat for everyone stayed retriable. Add wrapRedditError, built on redditchat.ExtractRedditError, to classify rate limits, expired sessions, forbidden and other permanent 4xx responses, and transient server or network failures into statuses with a reason, truthful certainty and a short human message. Permanent rejections use bridgev2 sentinels or FAIL_PERMANENT statuses, and image upload failures no longer surface the internal HTTP status text. --- pkg/connector/emoji.go | 2 +- pkg/connector/handlematrix.go | 21 ++++++----- pkg/connector/media.go | 12 +++++++ pkg/connector/membership.go | 8 ++--- pkg/connector/redditerrors.go | 66 +++++++++++++++++++++++++++++++++++ pkg/connector/requests.go | 12 +++---- 6 files changed, 99 insertions(+), 22 deletions(-) create mode 100644 pkg/connector/redditerrors.go diff --git a/pkg/connector/emoji.go b/pkg/connector/emoji.go index ea9f860..21d2944 100644 --- a/pkg/connector/emoji.go +++ b/pkg/connector/emoji.go @@ -126,7 +126,7 @@ func (r *RedditClient) resolveOutgoingReaction(ctx context.Context, value string } else if key, ok := nativeReactionKey(value); ok { return key, nil } - return "", errors.New("reddit supports its native reaction palette; choose an imported Reddit reaction or a matching emoji") + return "", permanentFailure(event.MessageStatusUnsupported, "reddit only supports its own reactions") } var _ bridgev2.StickerImportingNetworkAPI = (*RedditClient)(nil) diff --git a/pkg/connector/handlematrix.go b/pkg/connector/handlematrix.go index cb13f17..2490f74 100644 --- a/pkg/connector/handlematrix.go +++ b/pkg/connector/handlematrix.go @@ -3,7 +3,6 @@ package connector import ( "context" "errors" - "fmt" "time" "maunium.net/go/mautrix" @@ -24,7 +23,7 @@ var ( func (r *RedditClient) HandleMatrixMessage(ctx context.Context, msg *bridgev2.MatrixMessage) (*bridgev2.MatrixMessageResponse, error) { if !r.IsLoggedIn() { - return nil, errors.New("not logged in to Reddit") + return nil, bridgev2.ErrNotLoggedIn } txn, err := r.outgoingTransaction(msg.Portal, msg.Event, msg.InputTransactionID) if err != nil { @@ -44,7 +43,7 @@ func (r *RedditClient) HandleMatrixMessage(ctx context.Context, msg *bridgev2.Ma switch content.MsgType { case event.MsgImage: if err = r.sendMedia(ctx, content); err != nil { - return nil, err + return nil, mediaSendStatus(err) } case event.MsgText, event.MsgNotice, event.MsgEmote: default: @@ -52,7 +51,7 @@ func (r *RedditClient) HandleMatrixMessage(ctx context.Context, msg *bridgev2.Ma } resp, err := r.remote().SendMessage(ctx, portalIDToRoomID(msg.Portal.ID), content, mautrix.ReqSendEvent{TransactionID: txn}) if err != nil { - return nil, fmt.Errorf("send to reddit: %w", err) + return nil, wrapRedditError(err) } if resp.EventID == "" { return nil, errors.New("reddit returned no message identity") @@ -89,7 +88,7 @@ func (r *RedditClient) HandleMatrixMessageRemove(ctx context.Context, msg *bridg reason = msg.Content.Reason } _, err = r.matrix().RedactEvent(ctx, roomID, target, mautrix.ReqRedact{Reason: reason, TxnID: txn}) - return err + return wrapRedditError(err) } func (r *RedditClient) PreHandleMatrixReaction(ctx context.Context, msg *bridgev2.MatrixReaction) (bridgev2.MatrixReactionPreResponse, error) { @@ -118,13 +117,13 @@ func (r *RedditClient) HandleMatrixReaction(ctx context.Context, msg *bridgev2.M return nil, err } if msg.PreHandleResp == nil || msg.PreHandleResp.EmojiID == "" { - return nil, errors.New("reaction has no emoji") + return nil, permanentFailure(event.MessageStatusUnsupported, "reaction has no emoji") } roomID := portalIDToRoomID(msg.Portal.ID) emoji := string(msg.PreHandleResp.EmojiID) resp, err := r.matrix().SendMessageEvent(ctx, roomID, event.EventReaction, &event.ReactionEventContent{RelatesTo: event.RelatesTo{Type: event.RelAnnotation, EventID: target, Key: emoji}}, mautrix.ReqSendEvent{TransactionID: txn}) if err != nil { - return nil, err + return nil, wrapRedditError(err) } if resp.EventID == "" { return nil, errors.New("reddit returned no reaction identity") @@ -146,7 +145,7 @@ func (r *RedditClient) HandleMatrixReactionRemove(ctx context.Context, msg *brid } roomID := portalIDToRoomID(msg.Portal.ID) _, err = r.matrix().RedactEvent(ctx, roomID, meta.RemoteEventID, mautrix.ReqRedact{TxnID: txn}) - return err + return wrapRedditError(err) } func (r *RedditClient) HandleMatrixReadReceipt(ctx context.Context, msg *bridgev2.MatrixReadReceipt) error { @@ -154,7 +153,7 @@ func (r *RedditClient) HandleMatrixReadReceipt(ctx context.Context, msg *bridgev if msg.ExactMessage == nil { return nil } - return r.remote().MarkRead(ctx, roomID, messageIDToEventID(msg.ExactMessage.ID)) + return wrapRedditError(r.remote().MarkRead(ctx, roomID, messageIDToEventID(msg.ExactMessage.ID))) } func (r *RedditClient) HandleMatrixTyping(ctx context.Context, msg *bridgev2.MatrixTyping) error { @@ -164,7 +163,7 @@ func (r *RedditClient) HandleMatrixTyping(ctx context.Context, msg *bridgev2.Mat return nil } roomID := portalIDToRoomID(msg.Portal.ID) - return r.remote().SetTyping(ctx, roomID, msg.IsTyping, 30*time.Second) + return wrapRedditError(r.remote().SetTyping(ctx, roomID, msg.IsTyping, 30*time.Second)) } func (r *RedditClient) HandleMatrixRoomName(ctx context.Context, msg *bridgev2.MatrixRoomName) (bool, error) { @@ -177,7 +176,7 @@ func (r *RedditClient) HandleMatrixRoomName(ctx context.Context, msg *bridgev2.M } _, err := r.matrix().SendStateEvent(ctx, roomID, event.StateRoomName, "", msg.Content) if err != nil { - return false, err + return false, wrapRedditError(err) } msg.Portal.Name = msg.Content.Name msg.Portal.NameSet = true diff --git a/pkg/connector/media.go b/pkg/connector/media.go index 5407a8e..9562dfd 100644 --- a/pkg/connector/media.go +++ b/pkg/connector/media.go @@ -53,6 +53,18 @@ func mediaError(ctx context.Context, operation string, err error) error { return safe } +// The message is never sent when its media fails, so the failure is certain. +func mediaSendStatus(err error) error { + if errors.As(err, new(bridgev2.MessageStatus)) { + return err + } + status := bridgev2.WrapErrorInStatus(err).WithIsCertain(true).WithErrorReason(event.MessageStatusNetworkError).WithMessage("failed to upload image") + if isUnbridgeable(err) { + return status.WithStatus(event.MessageStatusFail) + } + return status +} + func copyMediaInfo(content *event.MessageEventContent) { if content.Info == nil { content.Info = &event.FileInfo{} diff --git a/pkg/connector/membership.go b/pkg/connector/membership.go index 324a6d8..763541b 100644 --- a/pkg/connector/membership.go +++ b/pkg/connector/membership.go @@ -43,7 +43,7 @@ func (r *RedditClient) HandleMatrixMembership(ctx context.Context, msg *bridgev2 roomID := portalIDToRoomID(msg.Portal.ID) state, key, err := r.resolveRoomState(ctx, roomID, &mautrix.SyncJoinedRoom{}, &roomSnapshots{}) if err != nil { - return nil, err + return nil, wrapRedditError(err) } if key != msg.Portal.PortalKey || state.Type != "private_group" { return nil, bridgev2.ErrMembershipNotSupported @@ -56,10 +56,10 @@ func (r *RedditClient) HandleMatrixMembership(ctx context.Context, msg *bridgev2 targetID = networkid.UserID(target.ID) } if !strings.HasPrefix(string(targetID), "t2_") || strings.Trim(string(targetID)[3:], "0123456789abcdefghijklmnopqrstuvwxyz") != "" || len(targetID) <= 3 { - return nil, errors.New("membership target is not a Reddit account ID") + return nil, permanentFailure(event.MessageStatusUnsupported, "only reddit accounts can be invited or removed") } if targetID == r.userID { - return nil, errors.New("use accept or leave for your own membership") + return nil, permanentFailure(event.MessageStatusUnsupported, "use accept or leave to change your own membership") } targetMXID := userIDToMatrix(targetID, "") if msg.Type == bridgev2.Invite { @@ -67,5 +67,5 @@ func (r *RedditClient) HandleMatrixMembership(ctx context.Context, msg *bridgev2 } else { _, err = r.matrix().KickUser(ctx, roomID, &mautrix.ReqKickUser{UserID: targetMXID}) } - return nil, err + return nil, wrapRedditError(err) } diff --git a/pkg/connector/redditerrors.go b/pkg/connector/redditerrors.go new file mode 100644 index 0000000..1b8c4f3 --- /dev/null +++ b/pkg/connector/redditerrors.go @@ -0,0 +1,66 @@ +package connector + +import ( + "errors" + "net/http" + + "maunium.net/go/mautrix" + "maunium.net/go/mautrix/bridgev2" + "maunium.net/go/mautrix/event" + + "github.com/beeper/reddit/pkg/redditchat" +) + +type redditFailure int + +const ( + redditFailureTransient redditFailure = iota + redditFailureRateLimited + redditFailureUnauthorized + redditFailureForbidden + redditFailureRejected +) + +func classifyRedditError(err error) redditFailure { + redditErr, ok := redditchat.ExtractRedditError(err) + switch { + case ok && (redditErr.StatusCode == http.StatusTooManyRequests || redditErr.ErrCode == mautrix.MLimitExceeded.ErrCode): + return redditFailureRateLimited + case isUnauthorized(err): + return redditFailureUnauthorized + case ok && redditErr.StatusCode == http.StatusForbidden: + return redditFailureForbidden + case isPermanentHTTPError(err): + return redditFailureRejected + default: + return redditFailureTransient + } +} + +// wrapRedditError maps a failed Reddit request to a message status without +// exposing provider error text. +func wrapRedditError(err error) error { + if err == nil { + return nil + } + status := bridgev2.WrapErrorInStatus(err) + if isUnbridgeable(err) { + return status.WithIsCertain(true).WithErrorReason(event.MessageStatusUnsupported).WithStatus(event.MessageStatusFail).WithMessage("reddit chat details are unavailable") + } + switch classifyRedditError(err) { + case redditFailureRateLimited: + return status.WithIsCertain(true).WithErrorReason(event.MessageStatusNetworkError).WithMessage("reddit is rate limiting requests, try again later") + case redditFailureUnauthorized: + return status.WithIsCertain(true).WithErrorReason(event.MessageStatusNetworkError).WithMessage("reddit session expired") + case redditFailureForbidden: + return status.WithIsCertain(true).WithErrorReason(event.MessageStatusNoPermission).WithStatus(event.MessageStatusFail).WithMessage("reddit doesn't allow this") + case redditFailureRejected: + return status.WithIsCertain(true).WithErrorReason(event.MessageStatusNetworkError).WithStatus(event.MessageStatusFail).WithMessage("reddit rejected this request") + default: + return status.WithIsCertain(false).WithErrorReason(event.MessageStatusNetworkError).WithMessage("couldn't reach reddit") + } +} + +func permanentFailure(reason event.MessageStatusReason, msg string) error { + return bridgev2.WrapErrorInStatus(errors.New(msg)).WithIsCertain(true).WithErrorReason(reason).WithStatus(event.MessageStatusFail).WithMessage(msg) +} diff --git a/pkg/connector/requests.go b/pkg/connector/requests.go index 983c552..f324e65 100644 --- a/pkg/connector/requests.go +++ b/pkg/connector/requests.go @@ -62,24 +62,24 @@ func (r *RedditClient) HandleMatrixDeleteChat(ctx context.Context, msg *bridgev2 return errors.New("chat belongs to another login") } if msg.Content.DeleteForEveryone { - return errors.New("reddit chats cannot be deleted for everyone") + return permanentFailure(event.MessageStatusUnsupported, "reddit chats can't be deleted for everyone") } roomID := portalIDToRoomID(msg.Portal.ID) state, key, err := r.resolveRoomState(ctx, roomID, &mautrix.SyncJoinedRoom{}, &roomSnapshots{}) if err != nil { - return err + return wrapRedditError(err) } if key != msg.Portal.PortalKey { return errors.New("chat ownership does not match Reddit room state") } switch state.Type { case "direct": - return r.matrix().SetRoomAccountData(ctx, roomID, "com.reddit.hidden_chat", map[string]bool{"hidden": true}) + return wrapRedditError(r.matrix().SetRoomAccountData(ctx, roomID, "com.reddit.hidden_chat", map[string]bool{"hidden": true})) case "private_group": _, err = r.matrix().LeaveRoom(ctx, roomID) - return err + return wrapRedditError(err) default: - return errors.New("unsupported Reddit chat type for deletion") + return bridgev2.ErrDeleteChatNotSupported } } @@ -131,7 +131,7 @@ func (r *RedditClient) HandleMatrixAcceptMessageRequest(ctx context.Context, msg roomID := portalIDToRoomID(msg.Portal.ID) joined, err := r.matrix().JoinRoomByID(ctx, roomID) if err != nil { - return err + return wrapRedditError(err) } if joined.RoomID != "" && joined.RoomID != roomID { return errors.New("reddit accepted a different conversation") From 11657e620869e766ca4bed3dad5da0b48293693e Mon Sep 17 00:00:00 2001 From: Nick Mills-Barrett Date: Mon, 19 Oct 2026 13:28:18 +0000 Subject: [PATCH 2/7] startchat: return RespErrors from login and provisioning Unexpected login steps, invalid start-chat input and Reddit failures in ResolveIdentifier, SearchUsers and CreateGroup were plain errors, so the provisioning API answered with a bare 500 M_UNKNOWN. Return bridgev2 RespErrors built from the standard mautrix codes, and map Reddit request failures through the shared classifier to 429, 403, 400 or 502 responses with a human message while keeping the original error in the chain for logs. --- pkg/connector/login.go | 6 +++--- pkg/connector/redditerrors.go | 20 ++++++++++++++++++++ pkg/connector/startchat.go | 24 +++++++++++------------- 3 files changed, 34 insertions(+), 16 deletions(-) diff --git a/pkg/connector/login.go b/pkg/connector/login.go index 56beeac..6381825 100644 --- a/pkg/connector/login.go +++ b/pkg/connector/login.go @@ -3,12 +3,12 @@ package connector import ( "context" "encoding/json" - "errors" "fmt" "net/http" "net/url" "strings" + "maunium.net/go/mautrix" "maunium.net/go/mautrix/bridgev2" "maunium.net/go/mautrix/bridgev2/database" "maunium.net/go/mautrix/bridgev2/status" @@ -134,7 +134,7 @@ func (p *PasswordLogin) SubmitUserInput(ctx context.Context, input map[string]st } return p.buildCaptchaStep(p.session.CaptchaRequest(redditchat.CaptchaStepOTP)), nil default: - return nil, errors.New("reddit login is not waiting for user input") + return nil, bridgev2.RespError(mautrix.MBadState.WithMessage("Reddit login is not waiting for input.").WithStatus(http.StatusBadRequest)) } } @@ -145,7 +145,7 @@ func (p *PasswordLogin) SubmitCookies(ctx context.Context, cookies map[string]st return nil, err } if p.step != StepIDCaptchaPassword && p.step != StepIDCaptchaOTP { - return nil, errors.New("reddit login is not waiting for verification") + return nil, bridgev2.RespError(mautrix.MBadState.WithMessage("Reddit login is not waiting for verification.").WithStatus(http.StatusBadRequest)) } captcha := redditchat.CaptchaResult{ Token: strings.TrimSpace(cookies[FieldRecaptchaToken]), diff --git a/pkg/connector/redditerrors.go b/pkg/connector/redditerrors.go index 1b8c4f3..f2445c6 100644 --- a/pkg/connector/redditerrors.go +++ b/pkg/connector/redditerrors.go @@ -2,6 +2,7 @@ package connector import ( "errors" + "fmt" "net/http" "maunium.net/go/mautrix" @@ -64,3 +65,22 @@ func wrapRedditError(err error) error { func permanentFailure(reason event.MessageStatusReason, msg string) error { return bridgev2.WrapErrorInStatus(errors.New(msg)).WithIsCertain(true).WithErrorReason(reason).WithStatus(event.MessageStatusFail).WithMessage(msg) } + +// wrapRedditRespError maps a failed Reddit request to a provisioning error. The +// original error stays in the chain for logs; only the RespError is written. +func wrapRedditRespError(err error) error { + var mapped bridgev2.RespError + switch classifyRedditError(err) { + case redditFailureRateLimited: + mapped = bridgev2.RespError(mautrix.MLimitExceeded.WithMessage("Reddit is rate limiting requests. Please try again later.")) + case redditFailureUnauthorized: + mapped = bridgev2.RespError(mautrix.MUnknown.WithMessage("Reddit session expired. Please try again shortly.").WithStatus(http.StatusBadGateway)) + case redditFailureForbidden: + mapped = bridgev2.RespError(mautrix.MForbidden.WithMessage("Reddit doesn't allow this request.")) + case redditFailureRejected: + mapped = bridgev2.RespError(mautrix.MUnknown.WithMessage("Reddit rejected the request.").WithStatus(http.StatusBadRequest)) + default: + mapped = bridgev2.RespError(mautrix.MUnknown.WithMessage("Failed to reach Reddit. Please try again.").WithStatus(http.StatusBadGateway)) + } + return fmt.Errorf("%w: %w", mapped, err) +} diff --git a/pkg/connector/startchat.go b/pkg/connector/startchat.go index 114d84d..b85d182 100644 --- a/pkg/connector/startchat.go +++ b/pkg/connector/startchat.go @@ -2,11 +2,10 @@ package connector import ( "context" - "errors" - "fmt" "strings" "go.mau.fi/util/ptr" + "maunium.net/go/mautrix" "maunium.net/go/mautrix/bridgev2" "maunium.net/go/mautrix/bridgev2/database" "maunium.net/go/mautrix/bridgev2/networkid" @@ -27,24 +26,24 @@ func (r *RedditClient) ResolveIdentifier(ctx context.Context, identifier string, identifier = strings.TrimPrefix(identifier, "u/") identifier = strings.TrimPrefix(identifier, "/u/") if identifier == "" { - return nil, errors.New("empty identifier") + return nil, bridgev2.RespError(mautrix.MInvalidParam.WithMessage("Enter a Reddit username.")) } results, err := r.remote().SearchUsers(ctx, identifier) if err != nil { - return nil, fmt.Errorf("search users: %w", err) + return nil, wrapRedditRespError(err) } var match *userDirectoryHit for _, hit := range results.Results { display := hit.DisplayName if strings.EqualFold(stripUserPrefix(display), identifier) || strings.EqualFold(string(hit.UserID), "@"+identifier+":reddit.com") { if match != nil && match.UserID != hit.UserID { - return nil, fmt.Errorf("multiple Reddit users matched %q", identifier) + return nil, bridgev2.RespError(mautrix.MInvalidParam.WithMessage("Multiple Reddit users match %q.", identifier)) } match = &userDirectoryHit{UserID: hit.UserID, Name: display, Avatar: string(hit.AvatarURL)} } } if match == nil { - return nil, fmt.Errorf("no Reddit user matched %q", identifier) + return nil, bridgev2.RespError(mautrix.MNotFound.WithMessage("No Reddit user found for %q.", identifier)) } uid := makeUserID(match.UserID) @@ -62,7 +61,7 @@ func (r *RedditClient) ResolveIdentifier(ctx context.Context, identifier string, } roomID, _, err := r.remote().CreateOrGetDM(ctx, match.UserID) if err != nil { - return nil, fmt.Errorf("create dm: %w", err) + return nil, wrapRedditRespError(err) } portalKey := networkid.PortalKey{ID: makePortalID(roomID), Receiver: r.userLogin.ID} resp.Chat = &bridgev2.CreateChatResponse{ @@ -79,7 +78,7 @@ func (r *RedditClient) SearchUsers(ctx context.Context, query string) ([]*bridge query = strings.TrimSpace(query) results, err := r.remote().SearchUsers(ctx, query) if err != nil { - return nil, err + return nil, wrapRedditRespError(err) } out := make([]*bridgev2.ResolveIdentifierResponse, 0, len(results.Results)) for _, hit := range results.Results { @@ -98,14 +97,13 @@ func (r *RedditClient) SearchUsers(ctx context.Context, query string) ([]*bridge } // CreateGroup spins up a private Reddit chat with the requested participants. -// Reddit has rate limits on group creation for new accounts; surface errors -// directly from the underlying library to the caller. +// Reddit has rate limits on group creation for new accounts. func (r *RedditClient) CreateGroup(ctx context.Context, params *bridgev2.GroupCreateParams) (*bridgev2.CreateChatResponse, error) { if params == nil || params.Name == nil || params.Name.Name == "" { - return nil, errors.New("group name is required") + return nil, bridgev2.RespError(mautrix.MInvalidParam.WithMessage("Group name is required.")) } if len(params.Participants) < 2 { - return nil, errors.New("at least 2 participants are required") + return nil, bridgev2.RespError(mautrix.MInvalidParam.WithMessage("At least 2 participants are required.")) } mxids := make([]id.UserID, 0, len(params.Participants)) for _, uid := range params.Participants { @@ -113,7 +111,7 @@ func (r *RedditClient) CreateGroup(ctx context.Context, params *bridgev2.GroupCr } roomID, err := r.remote().CreateGroup(ctx, params.Name.Name, mxids) if err != nil { - return nil, fmt.Errorf("create group: %w", err) + return nil, wrapRedditRespError(err) } portalKey := networkid.PortalKey{ID: makePortalID(roomID)} if r.main.Bridge.Config.SplitPortals { From 41d10c0ec0ee855a216ba699d69e417fd82f69b5 Mon Sep 17 00:00:00 2001 From: Nick Mills-Barrett Date: Mon, 19 Oct 2026 13:28:42 +0000 Subject: [PATCH 3/7] chatsync: use a human message for token refresh bad credentials The BAD_CREDENTIALS state sent when the chat token can't be refreshed put the raw refresh error in Message. Show a human message instead and move the raw error to Info. The explicit RELOGIN action stays, since the pinned mautrix version doesn't default BAD_CREDENTIALS to it yet. --- pkg/connector/chatsync.go | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/pkg/connector/chatsync.go b/pkg/connector/chatsync.go index 174bfb5..ab27de6 100644 --- a/pkg/connector/chatsync.go +++ b/pkg/connector/chatsync.go @@ -60,10 +60,15 @@ func (r *RedditClient) runSync(ctx context.Context, done chan struct{}) { } if refreshNeedsLogin(refreshErr) { log.Warn().Err(refreshErr).Msg("Reddit session requires reauthentication") + message := "Reddit session expired. Please sign in again." + if errors.Is(refreshErr, errRefreshAccountMismatch) { + message = "Reddit session belongs to a different account. Sign in again with the original account." + } r.userLogin.BridgeState.Send(status.BridgeState{ StateEvent: status.StateBadCredentials, Error: "reddit-token-refresh-failed", - Message: refreshErr.Error(), + Message: message, + Info: map[string]any{"go_error": refreshErr.Error()}, UserAction: status.UserActionRelogin, }) return From 9b2f65868ad761e791cfd56e919665251c2be99c Mon Sep 17 00:00:00 2001 From: Nick Mills-Barrett Date: Mon, 19 Oct 2026 13:28:53 +0000 Subject: [PATCH 4/7] loginerrors: build the unknown login error from mautrix.MUnknown Use mautrix.MUnknown.WithMessage instead of a RespError literal so the status code can't drift from the standard error code. --- pkg/connector/loginerrors.go | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/pkg/connector/loginerrors.go b/pkg/connector/loginerrors.go index 3b9a6d9..ac7a3fc 100644 --- a/pkg/connector/loginerrors.go +++ b/pkg/connector/loginerrors.go @@ -5,6 +5,7 @@ import ( "fmt" "net/http" + "maunium.net/go/mautrix" "maunium.net/go/mautrix/bridgev2" "github.com/beeper/reddit/pkg/redditchat" @@ -50,11 +51,7 @@ var ( ErrCode: "FI.MAU.REDDIT.LOGIN_REJECTED", StatusCode: http.StatusBadRequest, } - ErrLoginUnknown = bridgev2.RespError{ - ErrCode: "M_UNKNOWN", - Err: "Internal error logging in to Reddit", - StatusCode: http.StatusInternalServerError, - } + ErrLoginUnknown = bridgev2.RespError(mautrix.MUnknown.WithMessage("Internal error logging in to Reddit")) ) // The original error stays in the chain for logs; only the mapped RespError is From b58afca097cf5127646feb00daa78c82c1ca8a5a Mon Sep 17 00:00:00 2001 From: Nick Mills-Barrett Date: Mon, 19 Oct 2026 13:29:18 +0000 Subject: [PATCH 5/7] client: stop the sync loop in LogoutRemote The pinned bridgev2 only calls LogoutRemote when logging out, not Disconnect, so the sync loop kept polling with the logged-out token. Its 401 handling could then refresh the chat token from the saved session or send bridge states for a deleted login. Disconnect before logging out of Reddit's homeserver. --- pkg/connector/client.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/pkg/connector/client.go b/pkg/connector/client.go index 1bbccef..9543e1b 100644 --- a/pkg/connector/client.go +++ b/pkg/connector/client.go @@ -142,6 +142,8 @@ func (r *RedditClient) IsThisUser(ctx context.Context, userID networkid.UserID) } func (r *RedditClient) LogoutRemote(ctx context.Context) { + // Stop syncing first so a 401 after logout can't refresh the token again. + r.Disconnect() if !r.IsLoggedIn() { return } From e2fb857cea3b3443127909dc9edfdc1134aeccef Mon Sep 17 00:00:00 2001 From: Nick Mills-Barrett Date: Mon, 19 Oct 2026 13:29:51 +0000 Subject: [PATCH 6/7] redditchat: time out Reddit session HTTP requests The HTTP clients behind login and chat-token refresh had no timeout, so a hung Reddit response could block a login step or the sync loop's token refresh indefinitely. These are short request/response calls, so use the same 30 second timeout as the reaction image client. --- pkg/connector/client.go | 2 +- pkg/redditchat/login.go | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/pkg/connector/client.go b/pkg/connector/client.go index 9543e1b..4ff7cca 100644 --- a/pkg/connector/client.go +++ b/pkg/connector/client.go @@ -82,7 +82,7 @@ func restoreSession(meta *UserLoginMetadata) (*redditchat.RedditSession, error) if err != nil { return nil, err } - httpClient := &http.Client{Jar: jar} + httpClient := &http.Client{Jar: jar, Timeout: 30 * time.Second} session, err := redditchat.NewRedditSession(httpClient, "", "") if err != nil { return nil, err diff --git a/pkg/redditchat/login.go b/pkg/redditchat/login.go index 34bea12..e3dc73c 100644 --- a/pkg/redditchat/login.go +++ b/pkg/redditchat/login.go @@ -14,6 +14,7 @@ import ( "net/url" "regexp" "strings" + "time" ) const ( @@ -103,7 +104,7 @@ func NewRedditSession(httpClient *http.Client, baseURL, userAgent string) (*Redd if err != nil { return nil, err } - httpClient = &http.Client{Jar: jar} + httpClient = &http.Client{Jar: jar, Timeout: 30 * time.Second} } if httpClient.Jar == nil { jar, err := cookiejar.New(nil) From ac0612634a0dc317786455f872dc641f8780c57e Mon Sep 17 00:00:00 2001 From: Nick Mills-Barrett Date: Mon, 19 Oct 2026 15:25:06 +0000 Subject: [PATCH 7/7] connector: use a generic reason for permanently rejected Reddit requests --- pkg/connector/media.go | 2 +- pkg/connector/redditerrors.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pkg/connector/media.go b/pkg/connector/media.go index 9562dfd..dc21d5c 100644 --- a/pkg/connector/media.go +++ b/pkg/connector/media.go @@ -60,7 +60,7 @@ func mediaSendStatus(err error) error { } status := bridgev2.WrapErrorInStatus(err).WithIsCertain(true).WithErrorReason(event.MessageStatusNetworkError).WithMessage("failed to upload image") if isUnbridgeable(err) { - return status.WithStatus(event.MessageStatusFail) + return status.WithErrorReason(event.MessageStatusGenericError).WithStatus(event.MessageStatusFail) } return status } diff --git a/pkg/connector/redditerrors.go b/pkg/connector/redditerrors.go index f2445c6..76d53ee 100644 --- a/pkg/connector/redditerrors.go +++ b/pkg/connector/redditerrors.go @@ -56,7 +56,7 @@ func wrapRedditError(err error) error { case redditFailureForbidden: return status.WithIsCertain(true).WithErrorReason(event.MessageStatusNoPermission).WithStatus(event.MessageStatusFail).WithMessage("reddit doesn't allow this") case redditFailureRejected: - return status.WithIsCertain(true).WithErrorReason(event.MessageStatusNetworkError).WithStatus(event.MessageStatusFail).WithMessage("reddit rejected this request") + return status.WithIsCertain(true).WithErrorReason(event.MessageStatusGenericError).WithStatus(event.MessageStatusFail).WithMessage("reddit rejected this request") default: return status.WithIsCertain(false).WithErrorReason(event.MessageStatusNetworkError).WithMessage("couldn't reach reddit") }