feat(i18n): migrate modules/message to ResponseErrorL (Phase 2.1)#203
feat(i18n): migrate modules/message to ResponseErrorL (Phase 2.1)#203an9xyz wants to merge 9 commits into
Conversation
Add 23 err.server.message.* codes (pkg/errcode/message.go) covering the validation / permission / not-found / limit / internal shapes used across modules/message (api.go, api_manager.go, api_pinned.go, api_conversation.go and the smaller get/reminders/channel_files/sidebar files), plus their zh-CN runtime translations and the regenerated server marker. Internal failures collapse to query/store/notify/search buckets (Internal=true). No call sites migrated yet; this lays the registry the per-file migrations build on.
Add modules/message/api_i18n.go with the high-frequency respond helpers (request_invalid with optional field detail, pinned-limit with max detail, and the shared auth.required / auth.token_invalid guards used by the proxy-send path). Internal=true codes are called directly at each site so the existing zap logs are preserved.
Migrate all 138 legacy error-response sites in api.go (send / edit / read
/ reaction / revoke / delete / reminders / search handlers) to the
localized ResponseErrorL envelope:
- validation / not-found / permission / recall-window paths map to the
semantic err.server.message.* codes (wire status stays 400 per D14;
semantics in error.http_status / error.code);
- internal failures collapse to the Internal=true query/store/notify/
search buckets, each keeping (or gaining) an m.Error(..., zap.Error)
log; ResponseErrorf("msg", err) sites preserve the message as the log;
- proxy-send token guards reuse shared auth.required / auth.token_invalid;
authorizeMutualDelete / verifyRevokeMessageID sentinels map to
delete_forbidden (403) / id_seq_mismatch (400).
Add respondMessageForbidden helper for the management-console guards.
Migrate all 60 management-console error-response sites to ResponseErrorL: CheckLoginRole / CheckLoginRoleIsSuperAdmin guards β shared auth.forbidden (403); validation literals β request_invalid with field detail; banword / receiver / target-group not-found β the semantic 404 codes; sequence-gen / tx / record-write failures β Internal store/query/notify buckets with their m.Error logs preserved.
Migrate all 56 pinned-message error-response sites: validation literals β request_invalid with field; conversation/pinned permission guards β conversation_forbidden / pinned_forbidden / not_group_member (403); group-gone / message-gone β group_not_found / not_found (404); pinned cap β pinned_limit_exceeded; query / store / cmd failures β Internal buckets with their m.Error logs preserved.
β¦rorL Migrate all 44 conversation/sync error-response sites: validation β request_invalid with field; delete-conversation guards β conversation_forbidden / cannot_delete_self_conversation; the large set of offset/version/sync/query failures β Internal query/store/notify buckets with co.Error logs preserved.
Migrate the last 38 sites across api_message_get.go (12), api_reminders.go (11), api_channel_files.go (11) and api_sidebar.go (4): id/format validation β request_invalid with field; file-access friend/member guards β not_friend / not_group_member; errors.Wrap(err, "β¦") query sites and seq/sync/IM failures β Internal query/store/notify buckets with receiver logs. Drop the now-unused errors import from api_reminders.go. This completes the modules/message migration (336 sites / 8 files).
- TestMessageNoLegacyResponseError: source guard over all 8 migrated
files forbidding regression to .ResponseError(/.ResponseErrorf(/
.ResponseErrorWithStatus(/c.Response(".
- TestRespondMessageHelpers: table-driven coverage of the respond helpers
and a sampling of direct codes (error.code, semantic http_status,
dual-envelope parity, D14 transport=400, request_invalid/pinned-limit
details, Internal-no-leak).
- Also migrate the 3 ResponseErrorWithStatus(500) sync-failure sites in
api_conversation.go to ErrMessageQueryFailed (Internal); their co.Error
logs are preserved (wire 400 + error.http_status 500 per D14).
Jerry-Xin
left a comment
There was a problem hiding this comment.
The PR is relevant to octo-server, but the migration is incomplete for a migrated message API file.
π΄ Blocking
- π΄ Critical: modules/message/api_message_get.go still returns direct raw 404 JSON for not-found/invisible message cases. The same pattern appears at lines 186, 197, 224, 234, 245, 249, 264, and 291. These responses bypass
httperr.ResponseErrorL, do not includeerror.code, are not localized, and return wire status 404 instead of the D14 compatibility wire status 400. UseErrMessageNotFoundfor all indistinguishable not-found/access-denied cases; that preserves the no-enumeration behavior while keeping the i18n envelope.
π¬ Non-blocking
- π‘ Warning: modules/message/api_pinned.go returns
ErrMessagePinnedLimitExceededdirectly, so the newrespondMessagePinnedLimitExceededhelper and its safemaxdetail are never used by the real handler. - π΅ Suggestion: modules/message/api_conversation.go and line 1084 log internal query failures without
zap.Error(err), which weakens the βinternal details in logs, not on the wireβ rule. - π΅ Suggestion: modules/message/api_pinned.go logs the same store failure twice.
β Highlights
- The new
ErrMessage*registry entries are structured consistently and mark 5xx buckets asInternal=true. - The helper tests cover localized envelopes, shared auth codes, safe details, and internal-message suppression.
- Legacy
ResponseErrorusage is removed from the targeted files.
Validation note: go test ./modules/message ./pkg/errcode ./pkg/i18n/... could not complete locally because modules/message test setup requires Redis at 127.0.0.1:6379; the i18n and errcode packages passed before that failure.
lml2468
left a comment
There was a problem hiding this comment.
[REQUEST_CHANGES]
Solid Phase 2.1 migration β 336 sites across 8 files, error classification is thorough, and the test guard + helper coverage matches the group module playbook. However, there is one blocking gap that Jerry-Xin already identified, plus a few additional items.
π΄ Critical (blocking)
1. api_message_get.go: 9 raw c.JSON(http.StatusNotFound, ...) calls bypass the i18n envelope entirely β agreeing with Jerry-Xin's finding. These return wire status 404 with {"msg": "message not found"} which violates the D14 contract (wire 400, semantic status in error.http_status), is not localized, and has no error.code. These are visibility-masking responses (treating not-found and access-denied identically) β ErrMessageNotFound already has exactly the right semantics for this. The TestMessageNoLegacyResponseError source guard does not catch these because it looks for c.ResponseError( not c.JSON(.
π‘ Warning (blocking)
2. api_message_get.go: 7 duplicate m.Error log calls β the migration introduced a second m.Error call at lines 132, 177, 190, 219, 240, 259, 269 while preserving the original. Each error now logs twice with the same message. Example at line 131-133:
m.Error("ζ₯θ―’εεΊε€±θ΄₯", zap.Error(err)) // original
m.Error("ζ₯θ―’εεΊε€±θ΄₯", zap.Error(err)) // added by migration
httperr.ResponseErrorL(c, errcode.ErrMessageQueryFailed, nil, nil)Remove the duplicates β one log per error is sufficient.
3. api.go line 2246-2247: duplicate m.Error in revoke β same pattern:
m.Error("ζ₯θ―’IMζΆζ―ιθ――", zap.String("fakeChannelID", fakeChannelID), ...)
m.Error("ζ₯θ―’IMζΆζ―ιθ――", zap.Error(err)) // should merge into the line aboveThese should be a single m.Error call with all fields.
π΅ Suggestions (non-blocking)
-
api_pinned.go:143: callshttperr.ResponseErrorL(c, errcode.ErrMessagePinnedLimitExceeded, nil, nil)directly instead of usingrespondMessagePinnedLimitExceeded(c, maxCount), so themaxsafe detail is never surfaced to the client. The helper exists but is dead code. -
api_conversation.go: Jerry-Xin noted twoco.Errorcalls missingzap.Error(err)β agree this weakens the "log internally, not on the wire" contract.
β Highlights
- 23 new
err.server.message.*codes well-organized by class with properInternal=trueon 500 buckets. ErrMessageSearchFailedas a dedicated internal bucket is good β separates external search-service failures from DB failures.TestMessageNoLegacyResponseErrorsource guard covers all 8 files.- Helper tests cover localized envelopes, shared auth codes, detail keys, and internal-message suppression.
CI: Build β | Lint β | Vet β | i18n β | Test pending.
yujiawei
left a comment
There was a problem hiding this comment.
Code Review β PR #203 (octo-server)
Verdict: APPROVED
This is a clean, well-executed Phase 2.1 i18n migration of modules/message (336 legacy error-response sites across 8 handler files β the localized httperr.ResponseErrorL facade). I reviewed all 13 changed files against the failure modes that bit the earlier thread/user migrations (transaction lifecycle, semantic-status regressions, internal-error leakage, wrong code mapping, dropped returns, auth-guard mapping, detail-key safety). The migration introduces no behavioral regression and is safe to merge.
Below are the verification results, then three latent (pre-existing) transaction-leak observations and a few minor cleanups. None of the latter block merge.
1. Verification results
| Check | Result | Evidence |
|---|---|---|
| Wire status preserved | β | Legacy c.ResponseError* always returned HTTP 400; ResponseErrorL hard-codes transport 400 during the compat window and carries the corrected semantic status in error.http_status. No client-visible status change. |
| Code β status mapping | β | not_friend / peer_not_in_space / channel_access_denied / delete_forbidden / recall_forbidden / not_group_member β 403; *_not_found β 404; id_seq_mismatch / recall_time_exceeded / request_invalid β 400; query/store/notify/search failures β 500 Internal=true. All semantically faithful to the original Chinese strings. |
ResponseErrorWithStatus(β¦, 500) sites |
β | The 3 explicit-500 sites (api_conversation.go, querying external group / default space / joined groups) all map to ErrMessageQueryFailed (500, Internal=true) with the underlying err still logged. Intent preserved. |
| Internal-error leak | β | Every Internal=true site keeps (or newly adds) m.Error(..., zap.Error(err)); the wire response carries no raw error. The renderer collapses Internal=true codes to err.shared.internal and empties details (verified in pkg/i18n/renderer.go + TestRespondMessageHelpers wantNotContains). Two previously-unlogged conversation sites gained logging β a net improvement. api_reminders.go:53 also fixes a prior leak (nextReminderSeq err used to be surfaced raw via c.ResponseError(err); now mapped + logged). |
| Auth guards | β | Proxy-send path: θ―·ε
η»ε½ β err.shared.auth.required (401), token errors β err.shared.auth.token_invalid (401); management-console role guards β err.shared.auth.forbidden (403). Shared statuses confirmed in pkg/i18n/codes/shared_test.go. |
return retained |
β | Every migrated site keeps its trailing return; no fallthrough / double-response. No live legacy c.ResponseError calls remain (only commented-out breadcrumbs). |
| Detail-key safety | β | Only SafeDetailKeys (field, max) are ever populated; Details.FilterBy(registered) strips anything else. No PII. |
| Locale completeness | β | All 23 err.server.message.* codes have exact 1:1 entries in both active.zh-CN.toml and the active.en-US.toml markers; en-US markers match DefaultMessage byte-for-byte; no orphan/missing keys; valid TOML. |
| Build / tests | β | go build ./modules/message/... ./pkg/errcode/... passes. New TestMessageNoLegacyResponseError (source guard) + TestRespondMessageHelpers (status/detail/no-leak) pass. |
2. Findings
P2 β Latent transaction-connection leaks (pre-existing, NOT introduced by this PR)
Three error paths inside a tx := Begin() span return without calling tx.Rollback(), and the surrounding defer only rolls back on recover() (panic), not on a normal early return. Each leaks the open transaction + its DB connection on that path.
modules/message/api_manager.go:166-170βdelete()loop,genMessageExtraSeqerror path.modules/message/api_reminders.go:53-57βreminderDone()loop,nextReminderSeqerror path.modules/message/api.go:2324-2328and2382-2386βrevoke(),genMessageExtraSeqanddeletePinnedMessageerror paths.
In every one of these functions, the sibling error paths do call tx.Rollback() (e.g. api_manager.go:181/191/207), so the omission is clearly unintentional. Example (api_manager.go):
version, err := m.genMessageExtraSeq(fakeChannelID)
if err != nil {
m.Error("ηζζΆζ―ζ©ε±εΊεε·ε€±θ΄₯", zap.Error(err))
httperr.ResponseErrorL(c, errcode.ErrMessageStoreFailed, nil, nil)
return // β no tx.Rollback(); defer is panic-only β tx + conn leak
}Why this is P2 and not a blocker:
- Pre-existing.
git show <base>:β¦confirms these blocks are byte-identical in the base β the migration only swappedc.ResponseError(err)forResponseErrorL(...)+ added a log, preserving the existing control flow. No regression vs. the currentmain. - Not on-demand exploitable. The leak only fires when
GenSeq/nextReminderSeq(a Redis/DB sequence call) itself fails β an infrastructure failure, not something a caller can force with crafted input. So this is connection-pool amplification during a DB/Redis incident, not a standalone DoS primitive.
Recommendation (strongly suggested, since these exact lines are already being edited): add tx.Rollback() before the response on each of the four paths, or replace the panic-only defer with defer tx.RollbackUnlessCommitted() (the pattern already used correctly by messageEdit() at api.go:741 and the offset/readed handlers). It's a 1-line-per-site change that closes a real latent leak while the code is open. If you'd rather keep this PR scoped strictly to i18n, please file a tracking issue rather than letting it ride β these are easy to lose once the lines stop being touched.
β οΈ For the human reviewer (security-sensitive PR): the three tx-leak paths above are the items worth a manual eye. They are not regressions and not on-demand exploitable, but they are genuine latent resource leaks on DB-mutation paths that two regular-user endpoints (/v1/message/revoke,/v1/message/reminder/done) and one admin endpoint (DELETE /v1/manager/message) pass through.
P2 β Read-path failures mapped to StoreFailed instead of QueryFailed (cosmetic)
modules/message/api_conversation.go:252 (ExistMember, a pure SELECT count(*)) and :268 (GetUser, a read) map to ErrMessageStoreFailed, but the errcode docstrings reserve StoreFailed for mutations and QueryFailed for reads β and a sibling read at :1132 already uses QueryFailed. No user-facing impact (both are Internal=true, semantic 500, wire 400, no message leaked); this only affects log-grep/category consistency. Safe to fix or defer.
Nits (non-blocking)
pkg/errcode/message.go:55βErrMessageCannotDeleteSelfConversation(correctly 400) is filed under the// permission (403)comment block; move it to the// validation (400)block for readability. Status is correct as-is.modules/message/api_i18n_test.go:54βwireI18nRendererForMessageTestis defined but never called (dead code, plus an otherwise-unusedserverimport). Either delete it or use it to add one end-to-end case that routes through real server boot wiring (the current suite validates helpers/renderer in isolation, not the 336 migrated call sites or themain.gorenderer wiring).modules/message/api_i18n_test.go:30β the legacy-guard's hard-coded 8-file list is complete today but won't auto-cover a futureapi_*.gohandler; consider afilepath.Globwalk.- Several read-path error sites in
api_message_get.gonow emit the underlying error twice (a pre-existingm.Error(...)plus a newly-added duplicatem.Error(..., zap.Error(err))), and at:218-219the duplicate drops the richerchannel_id/message_idcontext. Trim the duplicate log lines. Cosmetic log-volume issue only. - A few
respondMessageRequestInvalid(c, "")sites (api_channel_files.gocheck(),api_sidebar.go:222validateSidebarRequest) collapse distinct validation messages into one generic 400 with nofielddetail. Status is unchanged; only client diagnosability is reduced. Consistent with the compat-window design β optional to enrich later.
3. Summary
The migration faithfully preserves wire behavior, maps every code to the correct semantic status, never leaks internal errors, and is well covered by new tests. The only items of substance are three pre-existing transaction-rollback omissions that this PR happens to touch β I recommend fixing them in place (cheap, real) but they do not regress current behavior and are not a merge blocker. Approving.
β¦404s + dedup logs
Reviewer feedback (Jerry-Xin + lml2468):
- Critical: api_message_get.go had 9 raw c.JSON(http.StatusNotFound,{msg})
responses bypassing the i18n envelope (no error.code, not localized,
wire 404 instead of D14 400). Migrate all to ErrMessageNotFound
(visibility-masking keeps no-enumeration while carrying the envelope).
- Extend TestMessageNoLegacyResponseError to also forbid raw non-OK
c.JSON(http.Statusβ¦) responses.
- Dedup logs: remove the duplicate m.Error from the errors.Wrap migration
in api_message_get.go; merge the double m.Error in api.go revoke.
- api_pinned.go: drop the duplicate m.Error; use
respondMessagePinnedLimitExceeded(c, maxCount) so the max detail is
surfaced (helper was dead code).
- api_conversation.go: add zap.Error(err) to the two ζ₯θ―’η¨ζ·θ―¦ζ
ε€±θ΄₯ logs.
Drop now-unused net/http and gin imports from api_message_get.go.
Jerry-Xin
left a comment
There was a problem hiding this comment.
The PR is in scope for octo-server and the migration is broadly consistent with the existing i18n error-response pattern.
π¬ Non-blocking
π‘ Warning β modules/message/api_conversation.go:252 and modules/message/api_conversation.go:268 map read failures during membership/user lookup to ErrMessageStoreFailed. Both are still internal 500 envelopes, so this is not a blocker, but ErrMessageQueryFailed would better match the new query/store bucket contract and keep error.code semantics cleaner for clients and observability.
β Highlights
- Legacy message-module error responses are covered by a source guard in
modules/message/api_i18n_test.go. - New message error codes are registered and have runtime translations plus extracted source markers.
- Internal errors consistently use
Internal=truecodes, avoiding raw error leakage.
Verification:
make i18n-lintpassed.make i18n-extract-checkpassed.go test ./pkg/errcode ./pkg/i18n/...passed.go test ./modules/message ./pkg/errcode ./pkg/i18n/...could not complete locally because Redis was not running at127.0.0.1:6379;modules/messagepanicked innewTestServer().
yujiawei
left a comment
There was a problem hiding this comment.
Code Review β PR #203 (octo-server)
Verdict: APPROVED
Phase 2.1 i18n migration of modules/message (336 legacy error-response sites across 8 handler files β the localized httperr.ResponseErrorL facade, plus 23 new err.server.message.* codes with full zh-CN + en-US marker parity). I reviewed all 13 changed files at head SHA 1afea165, with focus on the failure modes that bit the earlier thread/user migrations: transaction lifecycle, semantic-status regressions, Internal-error leakage, and auth-guard correctness.
The blocking finding raised earlier in this PR β raw c.JSON(http.StatusNotFound, β¦) calls in api_message_get.go bypassing the i18n envelope β has been resolved in the latest commit (fix(i18n): address PR #203 review β migrate raw c.JSON 404s + dedup logs). All 9 sites now return errcode.ErrMessageNotFound, the visibility-masking (no-enumeration) behavior is preserved, and a new source-guard assertion (TestMessageNoLegacyResponseError now also forbids non-OK c.JSON(http.Statusβ¦)) prevents regression.
Verification
| Check | Result |
|---|---|
go build ./modules/message/... ./pkg/errcode/... ./pkg/i18n/... |
β pass |
go vet ./modules/message/ |
β pass |
TestMessageNoLegacyResponseError (8-file source guard) |
β pass |
TestRespondMessageHelpers (helper / sentinel / Internal-no-leak / detail keys) |
β pass |
| Error-code β locale parity | β 23 registered = 23 zh-CN = 23 en-US, all IDs match |
Transaction rollback in migration-touched code (api_pinned.go clearPinnedMessage, api.go:735) |
β
every early-return branch rolls back / uses RollbackUnlessCommitted |
Auth guards (respondMessageNotLoggedIn/TokenInvalid/Forbidden) β 401/401/403 |
β correct semantics, all guards terminate the handler |
| Internal codes mask the wire message | β
ResponseErrorL honors Internal=true; no raw err reaches the client |
Raw non-OK c.JSON remaining in the 8 files |
β none |
Non-blocking findings (P2 β recommend in-place fix, not required for merge)
These are pre-existing or observability-only issues surfaced while reviewing the migrated control flow. None changes functional or security behavior, so none blocks merge β but since the migration is already editing these exact lines, fixing them here is cheaper than a follow-up.
P2-1 β Transaction not rolled back on genMessageExtraSeq failure (modules/message/api_manager.go:166-170, function delete())
The early return when genMessageExtraSeq fails does not call tx.Rollback(), unlike the sibling branches in the same loop (updateMsgExtraVersionAndDeletedTx, line ~181) and after the loop (line ~190). The defer only rolls back inside recover() (panic path), so a normal early return here leaks the open transaction / connection. This predates the PR (the old c.ResponseError(err) branch had the same gap) and the endpoint is CheckLoginRoleIsSuperAdmin-gated, so exploitability is low β but it's a one-line fix worth folding into this safety-focused pass. (Same class as the friendSure/addUser leaks fixed in #197.)
P2-2 / P2-3 β Internal=true response sites missing the zap.Error log (modules/message/api_conversation.go:378 and :918)
insertDeviceOffsets / insertUserLastOffsets failures respond with ErrMessageStoreFailed (Internal=true, message masked on the wire β correct) but without a preceding co.Error("β¦", zap.Error(err)). The helper functions only log on tx-begin / commit failure, not on the per-row insertOrUpdateTx error, so a row-level DB error in this path becomes invisible to ops. This is exactly the invariant the file's own header comment states ("every such site keeps β¦ an m.Error(β¦, zap.Error(err)) log so ops can debug from logs while the wire response carries no message"). Add the log at both call sites (matching e.g. api_conversation.go:1078).
Note on the wire-status semantics (not a finding)
pinnedMessage maps the "group missing/inactive" branch to ErrMessageGroupNotFound (semantic 404). Per the D14 design, ResponseErrorL always emits HTTP transport status 400; the 404 lives only in error.http_status, and the old code already returned the explicit "ηΎ€δΈεε¨ζε·²ε ι€" message β so this is not a new enumeration signal. Consistent with the thread/user/group migrations. No action needed.
Security-sensitive sign-off
This PR was classified security_sensitive. The migration does not alter any authorization decision, transaction boundary, or visibility-masking logic β it only swaps the response envelope. The api_message_get.go 404 anti-enumeration behavior (the highest-risk area) is preserved and now carries the i18n envelope. For a human reviewer: the only things worth a manual glance are the two P2 observability gaps above; neither affects the trust boundary.
Approving. The P2 items are recommended in-place fixes but do not block merge.
Summary
Phase 2.1 i18n migration of
modules/messageβ the next hot module afterthread(#176),user(#188 / #197) andgroup(#198). All 336 legacy error-response sites across 8 files migrate from the legacyc.ResponseError(...)/c.ResponseErrorf(...)/c.ResponseErrorWithStatus(...)envelope to the localizedhttperr.ResponseErrorLfacade, reusing the established playbook.Organized as 8 reviewable commits: code registry β respond helpers β per-file migration β tests.
api.goapi_manager.goapi_pinned.goapi_conversation.goapi_message_get.go/api_reminders.go/api_channel_files.go/api_sidebar.goWhat changed
err.server.message.*codes (pkg/errcode/message.go) + zh-CN runtime translations + regenerated AST marker. Codes group by class: validation (400), permission (403), not-found (404), recall window / pinned limit (400), andInternal=truequery / store / notify / search buckets (500).modules/message/api_i18n.gorespond helpers (respondMessageRequestInvalidwith field detail,respondMessagePinnedLimitExceededwith max detail, and the sharedauth.required/auth.token_invalid/auth.forbiddenguards for the proxy-send and management-console paths).Internal=truebuckets; every such site keeps (or gains) anm.Error(..., zap.Error(err))log so the underlying error is never put on the wire.ResponseErrorf("msg", err)anderrors.Wrap(err, "msg")sites preserve the original message as the log.authorizeMutualDeleteβdelete_forbidden(403),verifyRevokeMessageIDβid_seq_mismatch(400).Behavior change (frontend, please note)
Following the i18n design (D14), every migrated error response returns HTTP wire status 400 during the compatibility window; the semantic status lives in
error.http_statusand clients should branch onerror.code. Threeapi_conversation.gosync-failure sites that previously returned a real500now return a400envelope witherror.http_status: 500(Internal). This is consistent with the thread/user/group migrations already merged.Test plan
go build ./...go vet ./modules/message/make i18n-lint(D23 direct-error-response ratchet + unregistered-code)make i18n-extract-check(AST marker recall)go test ./modules/message ./pkg/errcode ./pkg/i18n/...(clean DB) β the full module's existing 642 test cases pass unchanged, since they assert the wire-400 contract that D14 preserves.TestMessageNoLegacyResponseError(source guard over all 8 files) andTestRespondMessageHelpers(helper / sentinel / Internal-no-leak / dual-envelope / detail keys).Follow-ups (non-blocking)
errors.Is) to replace the err-string matching used for business-vs-internal classification β the same shared follow-up tracked for thread/user/group.