[Discussion] i18n-incompatible error patterns #2392
Replies: 3 comments 2 replies
-
|
Another alternative is to allow embedding a service error within a service error. So the API error responses will have a stack of errors. Ex: {
"code": "APP-1004",
"message": {
"key": "error_appsvc_i18n_resolution",
"defaultValue": "Error resolving i18n for application"
},
"description": {
"key": "error_appsvc_i18n_resolution_desc",
"defaultValue": "An error occurred while resolving i18n keys for the application service."
},
"embeddedError": {
"code": "I18N-1001",
"message": {
"key": "error_i18nsvc_resolution",
"defaultValue": "Invalid i18n key"
},
"description": {
"key": "error_i18nsvc_resolution_desc",
"defaultValue": "The provided i18n key {{key}} does not conform to the expected format."
}
}
}This can go into multiple levels. Ex: {
"code": "APP-1004",
"message": {
"key": "error_appsvc_i18n_resolution",
"defaultValue": "Error storing i18n for application"
},
"description": {
"key": "error_appsvc_i18n_resolution_desc",
"defaultValue": "An error occurred while storing i18n keys for the application."
},
"embeddedError": {
"code": "I18N-1001",
"message": {
"key": "error_i18nsvc_resolution",
"defaultValue": "Error storing i18n values"
},
"description": {
"key": "error_i18nsvc_resolution_desc",
"defaultValue": "An error occurred while storing i18n values for the application."
},
"embeddedError": {
"code": "DB-1001",
"message": {
"key": "error_db_unexpected",
"defaultValue": "Unexpected value"
},
"description": {
"key": "error_db_unexpected_desc",
"defaultValue": "An unexpected value was encountered in the database query."
}
}
}
}However this may not be interpretable in UI layers in a UX friendly manner. Hence I also prefer your suggestion. But we need to evaluate and see whether it can cater all such requirement.
Can you come up with a example for this? What will be the templated error and error description for service A error? I guess it will have a templated identifier for a possible error returned from service B (Note that at service A we don't know which error being returned from service B). It's not just about passing parameters from underline service. We need to return it's error and/ or error description as well to narrow down the root cause. Also consider a scenario where service A invokes service B which invokes service C (A -> B -> C). Service C returns an error with the actual root cause for the failure. Also we have defined a pattern for templated placeholders. Let's follow the same pattern here if we're proceeding with that implementation |
Beta Was this translation helpful? Give feedback.
-
On the UX concern: I initially shared the same hesitation, but on further evaluation, the structured error chain actually provides more flexibility to the UI compared to a flat, pre-interpolated string. With a structured approach, the UI can decide how to render the error — for example, as a collapsible “Details” section, a breadcrumb, or even a stack trace-style view. In contrast, a flat string forces a single representation, effectively baking UI decisions into the backend. This approach allows different clients (e.g., CLI vs UI) to present the error in a way that best fits their context.
For example, in an A → B → C flow:
A → B → C example:Service C (leaf — Certificate service, actual root cause): Service B (Application service — doesn't know which cert error C returned): Service A (FlowExec — doesn't know which app-level error B returned): Wire response: On the client side, each layer can be resolved independently using the i18n keys. Parameters (e.g.,
|
Beta Was this translation helpful? Give feedback.
-
Alternative ApproachesAlternative 1: Flat
|
| Approach | i18n-safe | Root cause visible | API namespace clean | Structural change |
|---|---|---|---|---|
| embeddedError chain | Yes | Yes (full chain) | Yes | Moderate |
| Flat errors array | Yes | Partial (no causality) | Yes | Moderate |
| Pass-through | Yes | Yes (direct) | No | Minimal |
| Logs only | Yes | No (logs required) | Yes | None |
| RFC 9457 | Yes | Yes (full chain) | Yes | Large |
The embeddedError chain and the flat errors array are the two most practical options. The key decision is whether the client needs to understand causality (which error caused which) or simply participation (which errors occurred).
Beta Was this translation helpful? Give feedback.
Uh oh!
There was an error while loading. Please reload this page.
-
Background
Thunder's service layer uses a structured
ServiceErrortype where both the error title and description arecore.I18nMessagevalues — a staticKeyfor translation lookup and aDefaultValueEnglish fallback. The intent is that the translation system resolves the key at response time; theDefaultValueis only a last resort.This design works well when error messages are fully static. It breaks down in two related scenarios that are widespread in the current codebase.
Problems
1. Runtime values baked into
DefaultValueAt many call sites, a runtime value is embedded directly into
DefaultValuevia string concatenation orfmt.Sprintf:The
Keyis static, but the full message is constructed at Go runtime. A translation file maps keys to templates — but there is no mechanism to passcount,propName, oridpTypeinto that template after the fact. TheDefaultValuea translator receives is an already-interpolated English string. They cannot produce a parameterized translation for it.This affects ~20 call sites across application, authn/oauth, authn/otp, authn/google, flow/flowexec, design/layout, design/theme, idp, userschema, and system/i18n/mgt.
2. Cross-service error message embedding
When Service A wraps a client error from Service B, it reads B's
DefaultValueas a plain Go string and concatenates it into A'sDefaultValue:Here the problem compounds: B's
DefaultValueis the English fallback, not a resolved translation. A reads that English string and welds it into its own message. Even if B's key could have been translated, the translation is discarded at the point A reads.DefaultValue. A's key now points to a message that contains untranslatable English from B, forever.3. Inconsistent cross-service error propagation
There is no documented policy for whether Service A should re-wrap or pass through Service B's errors. Both patterns coexist today:
DefaultValue— which is where Problems 1 and 2 currently originate.Proposed solution
Parameterized
I18nMessageExtend
I18nMessagewith aParamsfield, and let the translation layer perform {placeholder} substitution at resolution time:Translation file entry:
error.layoutservice.layout_in_use_description = Das Layout wird von {count} Anwendung(en) verwendetThe translation layer substitutes
{count}at response time. TheDefaultValuealso uses the placeholder as fallback, keeping the contract consistent.For cross-service errors where A wraps B's message: A passes B's
KeyandParamsas a nested context, rather than readingDefaultValue. The exact representation (a secondI18nMessagefield onServiceError, aWrappedErrorfield, or just forwarding B's key as a param value) is a design decision for the team.Cross-service Error Propagation Policy
The codebase should be standardized around a single, agreed-upon approach from the options below.
Approach 1 — Re-wrap always
Service A always returns an A-typed error regardless of where the failure originated.
DefaultValueas a plain string and embedding it into A's message, which is exactly the bug being fixed. Re-wrap without parameterization is strictly worse than pass-through.Approach 2 — Pass-through always
Service A returns B's error as-is when the failure originates from B.
Key(and eventuallyParams) reach the caller without modification — translation works correctly.CERT-1001from the application service endpoint, which feels like an abstraction leak.Hybrid (suggested in the discussion)
Open questions
Do we adopt parameterized I18nMessage? If yes, what is the substitution contract — {name} placeholders, a dedicated struct, something else? Does the DefaultValue also use placeholders, or remain a pre-interpolated fallback?
For cross-service errors where A wraps B's client error: should A carry B's I18nMessage (key + params) as structured context, or is it acceptable to only surface A's own key with no reference to B's message?
Re-wrap vs pass-through as the default policy: which do we prefer, and should there be a documented rule distinguishing orchestration services from leaf/infra services?
Beta Was this translation helpful? Give feedback.
All reactions