Skip to content

feat(httputil)!: return translatable errors with a code, message key and parameters - #333

Open
mushrafmim wants to merge 1 commit into
OpenNSW:mainfrom
mushrafmim:feat/httputil-translatable-errors
Open

mushrafmim wants to merge 1 commit into
OpenNSW:mainfrom
mushrafmim:feat/httputil-translatable-errors

Conversation

@mushrafmim

@mushrafmim mushrafmim commented Oct 22, 2026 •

Copy link
Copy Markdown
Contributor

Summary

httputil.Error wrote {"error": "<English sentence>", "correlationId": "..."}. Clients couldn't translate it, couldn't tell errors apart without parsing text, and the status was picked by hand at every call site. Call sites also often passed err.Error() as the message, which leaked internal error text.

This PR replaces that with errors that are declared once. Each has a stable code, a category that decides the HTTP status, and a message and description carried as {key, defaultValue, params}. Placeholders use the i18next {{name}} syntax, so frontends can translate in the UI. A single WriteError sends anything that isn't a declared error as ErrInternal, so internal error text never reaches the client.

Type of Change

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to not work as expected)
  • Documentation update
  • Refactoring (no functional changes)
  • Performance improvement
  • Other (please describe):

Changes Made

  • message.go: Message{Key, DefaultValue, Params}. In the JSON body, defaultValue has its {{name}} placeholders filled in (in a single pass, so a param value is never expanded again) and params is kept. WithParams gives each message only the params its text uses.
  • errors.go:
    • Category, with one table mapping it to an HTTP status: invalid 400, unauthenticated 401, forbidden 403, notFound 404, conflict 409, tooManyRequests 429, unavailable 503, internal 500. An unknown category gives 500.
    • Error, with WithParams, WithDetails and Wrap. Each returns a copy, so declared values are never changed.
    • Error implements error, Unwrap, and an Is that compares by code, so errors.Is(err, &ErrX) matches after WithParams/Wrap.
    • FieldError for validation details.
    • ErrInternal (SYS-5000).
    • WriteError(w, r, err). It writes an *Error, found with errors.As, with its category's status. Any other error, including nil, is logged with the correlation ID and sent as ErrInternal. An *Error is logged only when it carries a cause or wrapping context: at error level for 5xx categories, warn level otherwise.
  • catalog.go: Catalog(errs ...Error) returns every key with its English default, including ErrInternal's, for a test and a go generate step that writes en.json. It reports duplicate codes, keys with conflicting defaults, malformed codes (^[A-Z][A-Z0-9]*-[0-9]{4}$), unknown categories and missing keys, all in one errors.Join.
  • response.go: removed ErrorResponse, Error(w, r, status, message) and InternalServerError. JSON, CorrelationID and CorrelationIDFunc are unchanged.
  • Docs: rewrote the errors section of httputil/README.md (declaring, returning, writing, validation, catalog) and updated the root README row.

Response body:

{
  "code": "PB-1003",
  "message":     { "key": "pb.member.not_found", "defaultValue": "Member not found" },
  "description": { "key": "pb.member.not_found_description",
                   "defaultValue": "No member has the ID m-42",
                   "params": { "memberId": "m-42" } },
  "details": [],
  "correlationId": "c0ffee"
}

description is omitted when empty, details is always an array, and correlationId is omitted when CorrelationIDFunc is unset.

Testing

  • I have tested this change locally
  • I have added tests that prove my fix is effective or that my feature works
  • I have tested edge cases
  • All existing tests pass

go test -race ./... in httputil passes with 100% statement coverage. The tests cover:

  • the category-to-status table
  • interpolation edge cases: unknown or unclosed placeholders, values containing placeholders
  • per-message param filtering
  • that WithDetails copies don't share slices
  • errors.Is/As through wrapping
  • the exact response body
  • that unknown and nil errors never leak
  • log levels for causes and wrapping context
  • every Catalog rejection case

Checklist

  • My code follows the project's style guidelines
  • I have performed a self-review of my code
  • I have commented my code, particularly in hard-to-understand areas
  • I have made corresponding changes to the documentation
  • My changes generate no new warnings
  • I have checked that there are no merge conflicts

Related Issues

Closes #332

Screenshots/Demo

N/A

Additional Notes

  • Breaking change: consumers must replace httputil.Error(...) with a declared httputil.Error returned through WriteError, and InternalServerError(...) with returning the wrapped error to WriteError. The error body changes shape, so frontends need updating too. No module in this repo imports httputil.
  • Known gap: Catalog only reads Error values, so keys used only in FieldError messages aren't in the generated catalog yet. That can be a follow-up, for example by letting Catalog also accept standalone Messages.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features
    • API errors now support translatable messages, categories, parameterized text, and field-level validation details.
    • Error responses include consistent status codes and correlation IDs. Unexpected errors return a generic internal error without exposing sensitive details.
    • Added support for generating translation catalogs from declared errors.
  • Changes
    • The previous error response helpers have been removed; error responses now use the structured error workflow.
  • Documentation
    • Updated guidance for declaring, translating, and returning API errors.

@coderabbitai

coderabbitai Bot commented Oct 22, 2026 •

Copy link
Copy Markdown

Review in Change Stack →

Important

  • 🔍 Trigger review

This repository does not receive automatic reviews because it has fewer than 10 stars.

Run configuration
  • Source mode: Auto
  • Repository: Unavailable
  • Branch: Unavailable
  • File: No configuration file from this repository applied
  • Commit: Unavailable
  • Layers: defaults
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: 3ecfee20-341c-43f7-b4c5-5a51728be791

No actionable comments were generated in the recent review.

ℹ️ Recent review info
⚙️ Run configuration
  • Source mode: Auto
  • Repository: Unavailable
  • Branch: Unavailable
  • File: No configuration file from this repository applied
  • Commit: Unavailable
  • Layers: defaults
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: d32692ba-d7e5-4eb7-9371-45d5d287247d
📥 Commits

Reviewing files that changed from the base of the PR and between b8c5d0c and 7647da9.

📒 Files selected for processing: 10
  • README.md
  • httputil/README.md
  • httputil/catalog.go
  • httputil/catalog_test.go
  • httputil/errors.go
  • httputil/errors_test.go
  • httputil/message.go
  • httputil/message_test.go
  • httputil/response.go
  • httputil/response_test.go
💤 Files with no reviewable changes: 1
  • httputil/response_test.go

Included review availability: This review used your included allowance. Your plan provides up to 1 included review per hour; 0 remain after this review.

Walkthrough

The PR replaces plain-string HTTP error helpers with translatable messages, categorized errors, and WriteError. It adds validation for translation catalogs and updates package documentation to describe the new error workflow.

Changes: Translatable HTTP errors

Layer / File(s) Summary
Message representation and interpolation
httputil/message.go, httputil/message_test.go
Message carries a translation key, default text, and optional parameters. Interpolation substitutes matching placeholders, and JSON encoding retains parameters.
Categorized errors and response writing
httputil/errors.go, httputil/errors_test.go, httputil/response.go, httputil/response_test.go, httputil/README.md, README.md
Error adds categories, messages, details, and wrapping. WriteError writes categorized responses and logs applicable errors. The previous response type and helper functions are removed. Documentation describes the new workflow.
Error catalog validation
httputil/catalog.go, httputil/catalog_test.go
Catalog returns default message values when declarations pass validation, and reports declaration or key conflicts.

Priority: Normal

Estimated code review effort: 3 (Moderate) | ~25 minutes

Change: Feature

Sequence Diagram(s)

sequenceDiagram
  participant HTTPHandler
  participant WriteError
  participant CorrelationID
  participant slog
  participant ResponseWriter
  HTTPHandler->>WriteError: pass request and error
  WriteError->>CorrelationID: get request correlation ID
  WriteError->>slog: log unrecognized error or wrapped context
  WriteError->>ResponseWriter: write status and structured error body
Loading

Suggested reviewers: saknarajapakshe

Merge Risk: Minimal · up to 7647d

The new error API is ready to merge after normal checks. Consumers will need to adopt the documented breaking API and response changes.

Pre-merge checks: Passed 3 · Failed 2

Failed Failed checks: 2 warnings

Check name Status Explanation Resolution
Linked Issues check Warning The implementation satisfies the main #332 requirements: it adds keyed messages with {{name}} parameters, stable codes, centralized category status mapping, Go error compatibility, validation detail… Extend the catalog generation API or declaration model so declared FieldError.Message keys and English defaults are included. Add tests that verify those keys appear in the catalog and generated en.json.
Docstring Coverage Warning Docstring coverage is 21.43% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 28 functions across 7 files. (2 skipped: … Write docstrings for the functions missing them to satisfy the coverage threshold.
Passed Passed checks: 3 passed
Check name Status Explanation
Title check Passed The title clearly and concisely describes the main breaking change: translatable httputil errors with stable codes, message keys, and parameters.
Description check Passed The description follows the required template and documents the purpose, breaking changes, implementation details, testing, checklist, issue reference, and known limitation.
Out of Scope Changes check Passed The changed implementation, tests, and documentation stay within #332. The new error types, WriteError, catalog validation, response changes, and removal of the old helpers implement the requested b…
Full details: Out of Scope Changes check

Explanation

The changed implementation, tests, and documentation stay within #332. The new error types, WriteError, catalog validation, response changes, and removal of the old helpers implement the requested breaking API replacement. The tests support those objectives, and no unrelated product behavior is introduced.

Full details: Linked Issues check

Explanation

The implementation satisfies the main #332 requirements: it adds keyed messages with {{name}} parameters, stable codes, centralized category status mapping, Go error compatibility, validation details, safe WriteError, and catalog validation. However, Catalog only adds Error.Message and Error.Description (httputil/catalog.go). It does not include keys used only by FieldError.Message, although #332 requires a catalog of every declared error key and requires translatable validation errors. The PR description confirms this limitation.

Resolution

Extend the catalog generation API or declaration model so declared FieldError.Message keys and English defaults are included. Add tests that verify those keys appear in the catalog and generated en.json.

Full details: Docstring Coverage

Explanation

Docstring coverage is 21.43% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 28 functions across 7 files. (2 skipped: 2 unsupported.)

Resolution

Write docstrings for the functions missing them to satisfy the coverage threshold.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create a new PR
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@mushrafmim mushrafmim self-assigned this Oct 22, 2026

@sthanikan2000 sthanikan2000 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for this. Stable codes, the HTTP status coming from a category, and unknown errors safely falling back to ErrInternal are all real improvements, and the tests are thorough. I'm requesting changes on three points: where these types live, a few heuristics, and how much OpenNSW-specific policy ends up in httputil.

Where the types live. As written, service code is meant to return httputil.Error (see the README example). So domain packages, repositories and Temporal activities would all import an HTTP package just to report "not found". Only the status table and WriteError are actually about HTTP; the message type, error type and catalog aren't. I'd split it so imports point from the HTTP side to the domain side:

apperror/             no net/http; service code imports only this
  Message, Category, Error, FieldError, Catalog
apperror/httperror/   the only package that knows both
  WriteError, the category→status table, the response body type
httputil/             stays generic, no dependencies
  JSON, CorrelationID, CorrelationIDFunc, plus a general status + body error writer

Names are up to you; the import direction is what matters.

Keeping httputil generic. With Error(w, r, status, msg) removed, the only way to write an error is through 8 categories. This repo's own storage (413, 415) and payment (422) handlers can't move over without changing their status codes. The code format, the SYS-5000 reservation, English defaults and i18next syntax are conventions for our services. That's another reason to keep them out of a shared HTTP helper.

Details are inline.

Comment thread httputil/README.md
}
```

Return it as an ordinary `error` from anywhere, and write it with `WriteError`:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the pattern I'm most concerned about. If services return httputil.Error, every package that wants a typed client error depends on httputil. That includes packages that never touch HTTP, like Temporal activities, taskflow and workflow. CategoryNotFound is fine as a domain concept, but declaring it shouldn't need an HTTP package. See the split suggested in the review summary.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These are HTTP response errors by design, not domain errors. The handler maps a service's own errors (e.g. member.ErrNotFound) to an httputil.Error and writes it. Services, repositories, Temporal activities, taskflow and workflow return their own errors and never import httputil. Activities don't respond to a frontend anyway, and a Go error type wouldn't survive Temporal's serialization.

The README example was misleading: it returned ErrMemberNotFound from a service method. I've updated the README and the Error doc comment so the mapping happens in the handler; that goes up with my next push. With that boundary, a transport-neutral apperror package would have no second consumer, so I'd rather not split until one exists.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This error pattern is specifically to send error response to frontends, if some service is not supposed to send errors to the frontend, they doesn't need to use this package.

Comment thread httputil/errors.go
CategoryInternal Category = "internal" // 500 Internal Server Error
)

var categoryStatus = map[Category]int{

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The category set is closed and the table is unexported, so any other category becomes a 500. With Error(w, r, status, msg) removed, there's no longer a way to send:

  • 413 (storage/local_content_handler.go:107)
  • 415 (storage/http_handler.go:70)
  • 422 (payment/handler.go:114)
  • 405, 502 or 504

Could we add categories for at least these (e.g. tooLarge, unsupportedMediaType, unprocessable, methodNotAllowed, badGateway, gatewayTimeout)? And could httputil keep a general writer for anything outside the table?

Comment thread httputil/errors.go
//
// errors.Is(err, &ErrX) matches any error derived from ErrX, since errors
// are compared by Code.
type Error struct {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One type is both the declared error and the error that gets returned, and it relies on mixed receivers to do it. The builder methods take the value, while Error/Is/Unwrap take the pointer. A side effect: return &ErrX (which the doc comment recommends) and apiErr = &ErrInternal hand out a pointer to the package-level var. Anything that gets it through errors.As can then change the declaration for every later request:

var got *httputil.Error
errors.As(fmt.Errorf("x: %w", &ErrMemberNotFound), &got)
got.Message.DefaultValue = "changed" // ErrMemberNotFound is now changed everywhere

Either of these would make declarations immutable and drop the mixed receivers:

  • a separate definition type whose methods always return a new *Error
  • unexported fields set by a constructor

Comment thread httputil/errors.go
}

// errorBody is the JSON shape of an API error response.
type errorBody struct {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The response shape is the public contract, but its type is unexported; the old ErrorResponse was exported. Go callers such as remote, service-to-service clients and consumers' tests would each have to redefine it to decode an error. Could we export it?

Comment thread httputil/errors.go
// from Wrap, or context added by wrapping it in another error. Server
// categories (internal, unavailable) log at error level, client categories
// at warn level.
func WriteError(w http.ResponseWriter, r *http.Request, err error) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The writer now owns the logging policy: whether to log, at what level, and with a fixed message. InternalServerError let the caller pass a message and structured attrs ...any. Here the only way to add context is wrapping with fmt.Errorf, so a field like itemId ends up as text inside error. Would WriteError(w, r, err, attrs ...any), passed through to the log line, work? It keeps the old behaviour without changing the response.

Comment thread httputil/errors.go
if !errors.As(err, &apiErr) || apiErr == nil {
slog.ErrorContext(r.Context(), "httputil: unhandled error", "error", err, "correlationId", correlationID)
apiErr = &ErrInternal
} else if apiErr.cause != nil || err != error(apiErr) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

err != error(apiErr) compares pointers as a stand-in for "the caller added context". Wrappers that add nothing all trip it, so a plain 404 gets logged at WARN:

WriteError(w, r, errors.Join(&ErrMemberNotFound))      // WARN
WriteError(w, r, fmt.Errorf("%w", &ErrMemberNotFound)) // WARN
WriteError(w, r, tracingWrapper{&ErrMemberNotFound})   // WARN

Whether a 4xx gets logged then depends on how deep in the call stack it was wrapped, and a burst of 401s or 429s becomes a burst of log lines. Making Wrap the only way to opt in would be explicit:

} else if apiErr.cause != nil {

Comment thread httputil/message.go
func (m Message) WithParams(params map[string]string) Message {
var used map[string]string
for name, value := range params {
if strings.Contains(m.DefaultValue, "{{"+name+"}}") {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Filtering params by searching the English DefaultValue has two side effects:

  • The English wording decides which params other languages receive. A param that a translation needs but the English text doesn't use is dropped.
  • Only the bare {{name}} form is recognised. {{max, number}}, {{ max }} and {{- max}} are all valid i18next, but each one drops the param and the client gets the raw placeholder as defaultValue:
Message{DefaultValue: "At most {{max, number}}"}.WithParams(map[string]string{"max": "64"})
// Params: nil; defaultValue sent as "At most {{max, number}}"

The filter is only needed because one WithParams call fills both Message and Description. Sending the full map, or putting params once on the error, avoids it. If filling in placeholders stays on the server, either handle those forms or document that only the bare form works.

Comment thread httputil/message.go
// client can either show the English text or translate Key with Params.
func (m Message) MarshalJSON() ([]byte, error) {
type plain Message
return json.Marshal(plain{Key: m.Key, DefaultValue: m.String(), Params: maps.Clone(m.Params)})

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: maps.Clone isn't needed here, since json.Marshal doesn't modify the map.

Comment thread httputil/catalog.go
"regexp"
)

var codePattern = regexp.MustCompile(`^[A-Z][A-Z0-9]*-[0-9]{4}$`)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The code format (^[A-Z][A-Z0-9]*-[0-9]{4}$) and the reserved SYS-5000 are OpenNSW conventions. If they stay, they belong with the error types (the apperror split in the summary) rather than in an HTTP helper. Alternatively, the consumer could pass the pattern in.

@mushrafmim mushrafmim Oct 25, 2026 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why this is NSW Specific? This is a generic pattern anybody can adopt based on their needs while adhering to this pattern, if we need anything specific in the future then we can make it customizable. You Aren't gonna need it (YAGNI)

Comment thread httputil/catalog.go
catalog[m.Key] = m.DefaultValue
}

for _, e := range append([]Error{ErrInternal}, errs...) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: Catalog adds ErrInternal on its own, and sameDeclaration exists only to tolerate that. It also compares fields by hand, so it will drift when a field is added. Letting callers list ErrInternal themselves would remove both.

On the known gap in the description: FieldErrors are written inline where they're returned and never reach Catalog. So the validation messages are exactly the ones translators won't see. It may be worth fixing that here rather than in a follow-up, e.g. by letting Catalog accept declared FieldErrors.

…and parameters

Replace the plain-string ErrorResponse with a declared Error type: a stable
code, a category that decides the HTTP status, and a message and description
carried as {key, defaultValue, params}. WriteError writes any *Error with its
category's status and sends every other error as ErrInternal, so internal
error text never reaches the client. Catalog lists every key with its English
default and catches duplicate codes and keys.

BREAKING CHANGE: ErrorResponse, Error(w, r, status, message) and
InternalServerError are removed in favour of Error, WriteError and
ErrInternal, and the error response body changes shape.

Closes OpenNSW#332

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@mushrafmim
mushrafmim force-pushed the feat/httputil-translatable-errors branch from 7647da9 to cddcb6c Compare October 26, 2026 00:08
Comment thread httputil/errors.go
}

// Error is a client-facing API error. Declare each one once, as a
// package-level value, and return it as an error with &ErrX, ErrX.WithParams,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking: a declared error is one shared value, so one request can change it for every later request.

httputil/errors.go:49 tells callers to return &ErrX. Error at httputil/errors.go:66 exports Code, Category, Message, and Description, and Error, Unwrap, and Is at httputil/errors.go:125, httputil/errors.go:134, and httputil/errors.go:140 use a pointer receiver. errors.As therefore returns the package-level variable. This writes through to every user of that declaration:

var got *httputil.Error
errors.As(fmt.Errorf("load: %w", &ErrMemberNotFound), &got)
got.Message.DefaultValue = "changed"

The same thing happens if a handler fills a parameter on &ErrX. A member id from one request then shows up in later responses. WithParams, WithDetails, and Wrap at httputil/errors.go:104, httputil/errors.go:112, and httputil/errors.go:119 are safe only because the receiver is a value, so they copy first. &ErrX skips that copy. WriteError also keeps &ErrInternal at httputil/errors.go:169.

Suggested fix: keep the package-level declaration as its own type, and make every method that returns an error return a new *Error (clone details with slices.Clone). Change httputil/errors.go:49 so the no-parameter return uses that method, for example ErrX.Err(), and use the same copy at httputil/errors.go:169.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

httputil: return translatable errors with a code, message key and parameters

3 participants