Errors & Validation
UDF-shaped error responses, input validation, and panic recovery
TradingView's Charting Library parses every /history and /symbols body as JSON and checks body.s. If it sees "error", it extracts body.errmsg and logs it; if it sees anything else it doesn't recognize (HTML, plain text, envelope), it throws an opaque browser-console error. All TV endpoints must therefore respond with valid UDF-shaped JSON, even in failure paths.
UDF error shape
{
"s": "error",
"errmsg": "<short, stable code or phrase>"
}sis always the literal string"error".errmsgis a short stable tag where possible (unknown_symbol,invalid resolution,timeout,internal_error) so clients can switch on it. Where a human phrase is more informative during development, we use one ("from must be <= to").- No envelope wrapping. No
data/messagekeys. No HTML.
Implementation: internal/modules/nepse/tradingview/write.go
func writeUDFError(w http.ResponseWriter, status int, errmsg string) {
writeJSON(w, status, map[string]string{"s": "error", "errmsg": errmsg})
}Handler-level validation
| Check | Status | errmsg |
|---|---|---|
symbol trimmed empty | 400 | symbol is required |
Service returns *response.StatusError with 404 | 404 | unknown_symbol |
| Context deadline exceeded (5s) | 504 | timeout |
| Any other error | 500 | internal_error |
/search does no strict validation — its job is to stay usable:
| Case | Behavior |
|---|---|
Missing query | treated as "" → browse-mode, return first N |
limit missing or invalid | clamp to default 30 |
limit > 100 | clamp to default 30 |
| DB error | log + return [] (never 5xx) |
| Timeout | log + return [] (never 504) |
Validation runs in order; the first failure short-circuits.
| Step | Check | errmsg |
|---|---|---|
| 1 | symbol empty after upper+trim | symbol is required |
| 2 | resolution not in {1, 5, 15, 30, 60, 1D, 1W, 1M} | invalid resolution |
| 3 | from or to query param missing | from and to are required |
| 4 | from not a parseable int | from must be a unix timestamp |
| 5 | to not a parseable int | to must be a unix timestamp |
| 6 | from > to | from must be <= to |
| 7 | from < 2000-01-01 UTC (946684800) or to > now + 48h | timestamps out of range |
All return 400. Past validation, runtime errors map to 504 (timeout) or 500 (internal_error).
Validation constants
const nepseEpoch int64 = 946684800 // 2000-01-01 UTC
var validResolutions = map[string]struct{}{
"1": {}, "5": {}, "15": {}, "30": {}, "60": {},
"1D": {}, "1W": {}, "1M": {},
}Timeout mapping
Each data-fetching handler wraps the request context with a 5-second budget:
ctx, cancel := tvContext(r) // 5s WithTimeout
defer cancel()
resp, err := h.svc.GetHistory(ctx, ...)
if err != nil {
if errors.Is(err, context.DeadlineExceeded) {
writeUDFError(w, http.StatusGatewayTimeout, "timeout")
return
}
...
}/search intentionally maps timeouts to [] instead of 504 — a blank autocomplete is recoverable; a 504 disables the search UI entirely for the rest of the session.
Panic recovery
chi's default middleware.Recoverer returns plain text on panic, which the TradingView JSON parser rejects. The TV group is wrapped with a second recoverer that emits UDF-shaped JSON:
// internal/modules/nepse/tradingview/recovery.go
func Recoverer() func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if rec := recover(); rec != nil {
slog.Error("tradingview: panic recovered",
"panic", rec,
"path", r.URL.Path,
"stack", string(debug.Stack()),
)
writeUDFError(w, http.StatusInternalServerError, "internal_error")
}
}()
next.ServeHTTP(w, r)
})
}
}Wired in router.go:
r.Group(func(r chi.Router) {
r.Use(rl60.Middleware)
r.Use(tradingview.Recoverer()) // ← JSON on panic
r.Use(chimw.Compress(5))
// ... TV routes
})A panic produces:
- HTTP status:
500 - Content-Type:
application/json - Body:
{"s":"error","errmsg":"internal_error"}
Status-code summary
| Code | Used when |
|---|---|
200 | Successful response. Also what /search returns on degraded error paths. |
400 | Any client-side validation failure. |
404 | Unknown symbol on /symbols. |
429 | Rate limit exceeded (rl60 responds — UDF-shaped body not guaranteed; this is the rate-limiter's format). |
500 | Server-side error past validation; includes recovered panics. |
504 | 5-second timeout exceeded on /history or /symbols. |
429 responses come from the shared httpmiddleware.RateLimiter and are not UDF-shaped — they use the standard error envelope ({"statusCode":429,"message":"...","errors":[]}). A client hammering the endpoint should implement exponential backoff rather than relying on the chart to parse the body.
Developer-side validation checklist
Before shipping an endpoint change, confirm:
- Every error path goes through
writeUDFError(notresponse.Error). - No handler ever writes HTML or plain text except
/time. -
context.DeadlineExceededis explicitly handled before the generic 500 branch. -
pgx.ErrNoRowsis mapped to a UDF-appropriate response (usuallyno_dataor 404unknown_symbol). - New endpoints are added to the TV router group so they inherit the Recoverer + rate limit + gzip.
References
- Write helpers:
internal/modules/nepse/tradingview/write.go - Recoverer:
internal/modules/nepse/tradingview/recovery.go - Handler validation:
internal/modules/nepse/tradingview/handler.go