Shop It Docs
TradingView UDF

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>"
}
  • s is always the literal string "error".
  • errmsg is 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/message keys. 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

CheckStatuserrmsg
symbol trimmed empty400symbol is required
Service returns *response.StatusError with 404404unknown_symbol
Context deadline exceeded (5s)504timeout
Any other error500internal_error

/search does no strict validation — its job is to stay usable:

CaseBehavior
Missing querytreated as "" → browse-mode, return first N
limit missing or invalidclamp to default 30
limit > 100clamp to default 30
DB errorlog + return [] (never 5xx)
Timeoutlog + return [] (never 504)

Validation runs in order; the first failure short-circuits.

StepCheckerrmsg
1symbol empty after upper+trimsymbol is required
2resolution not in {1, 5, 15, 30, 60, 1D, 1W, 1M}invalid resolution
3from or to query param missingfrom and to are required
4from not a parseable intfrom must be a unix timestamp
5to not a parseable intto must be a unix timestamp
6from > tofrom must be <= to
7from < 2000-01-01 UTC (946684800) or to > now + 48htimestamps 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

CodeUsed when
200Successful response. Also what /search returns on degraded error paths.
400Any client-side validation failure.
404Unknown symbol on /symbols.
429Rate limit exceeded (rl60 responds — UDF-shaped body not guaranteed; this is the rate-limiter's format).
500Server-side error past validation; includes recovered panics.
5045-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 (not response.Error).
  • No handler ever writes HTML or plain text except /time.
  • context.DeadlineExceeded is explicitly handled before the generic 500 branch.
  • pgx.ErrNoRows is mapped to a UDF-appropriate response (usually no_data or 404 unknown_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