Error Handling
Error Categories
HTML library errors fall into the following categories:
| Category | Sentinel Errors | Description |
|---|---|---|
| Input errors | ErrInputTooLarge, ErrInvalidHTML | Input content issues |
| Config errors | ErrInvalidConfig, ErrMultipleConfigs | Configuration issues |
| File errors | ErrFileNotFound, ErrInvalidFilePath | File operation issues |
| Processing errors | ErrProcessingTimeout, ErrMaxDepthExceeded | Processing issues |
| System errors | ErrProcessorClosed, ErrInternalPanic | Internal state issues |
errors.Is Pattern
Use errors.Is to match error types:
result, err := html.Extract(data)
if err != nil {
switch {
case errors.Is(err, html.ErrInputTooLarge):
slog.Warn("Input too large, please reduce document size")
case errors.Is(err, html.ErrInvalidHTML):
slog.Warn("Invalid HTML, please check input")
case errors.Is(err, html.ErrProcessingTimeout):
slog.Warn("Processing timeout, document may be too complex")
case errors.Is(err, html.ErrFileNotFound):
slog.Warn("File not found")
case errors.Is(err, html.ErrMaxDepthExceeded):
slog.Warn("DOM depth too deep, possibly maliciously crafted")
case errors.Is(err, html.ErrInternalPanic):
slog.Error("Internal panic recovered, please report this issue")
default:
slog.Error("Unknown error", "err", err)
}
}errors.As Pattern
Extract structured error information:
var inputErr *html.InputError
var configErr *html.ConfigError
var fileErr *html.FileError
if errors.As(err, &inputErr) {
fmt.Printf("Size %d exceeds limit %d\n", inputErr.Size, inputErr.MaxSize)
}
if errors.As(err, &configErr) {
fmt.Printf("Field %s value %v invalid: %s\n", configErr.Field, configErr.Value, configErr.Message)
}
if errors.As(err, &fileErr) {
fmt.Printf("File operation: %s\n", fileErr.SafePath())
}Context Cancellation
Use ExtractWithContext variants for cancellation support:
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
result, err := html.ExtractWithContext(ctx, data)
if err != nil {
switch {
case errors.Is(err, html.ErrProcessingTimeout):
// Library ProcessingTimeout fired (ctx.Err() may still be nil here)
case ctx.Err() == context.DeadlineExceeded:
// User context deadline exceeded
case ctx.Err() == context.Canceled:
// Manual cancellation
default:
// Other errors (ErrInvalidHTML, ErrInputTooLarge, etc.)
slog.Error("extraction failed", "err", err)
}
}Batch Errors
Batch processing results include partial successes and failures:
batch := p.ExtractBatch(pages)
for i, err := range batch.Errors {
if err != nil {
fmt.Printf("Item %d failed: %v\n", i, err)
}
}
fmt.Printf("Success: %d, Failed: %d, Cancelled: %d\n",
batch.Success, batch.Failed, batch.Cancelled)Error Recovery Strategies
In real-world applications, simply identifying the error type is not enough — you need different recovery strategies based on the error category.
Encoding Detection Failure
When HTML input lacks a <meta charset> declaration and auto-detection cannot determine the encoding, the library returns an error message prefixed with "encoding detection failed". This is a plain fmt.Errorf wrapped error (not a sentinel error, not a typed error) and can only be detected by matching the error message string.
Recovery strategy: try auto-detection first (leave Config.Encoding empty), then retry with a manually specified known encoding on failure.
package main
import (
"fmt"
"log"
"strings"
"github.com/cybergodev/html"
"golang.org/x/text/encoding/simplifiedchinese"
)
// extractWithEncodingFallback tries auto-detection first, then retries with a manual encoding on detection failure.
func extractWithEncodingFallback(data []byte, fallbackEncoding string) (*html.Result, error) {
// First attempt: auto-detect encoding (leave Config.Encoding empty)
result, err := html.Extract(data)
if err == nil {
return result, nil
}
// On encoding detection failure, retry with the manually specified encoding
if strings.Contains(err.Error(), "encoding detection failed") {
fmt.Printf("Auto-detection failed (%v), retrying with encoding %q...\n", err, fallbackEncoding)
cfg := html.DefaultConfig()
cfg.Encoding = fallbackEncoding
return html.Extract(data, cfg)
}
// Other errors (input too large, invalid HTML, etc.) are not retried
return nil, err
}
func main() {
// Build GBK-encoded HTML (no charset meta, may trigger auto-detection failure)
utf8HTML := `<html><head><title>Test</title></head>` +
`<body><article><h1>Title</h1><p>Hello World</p></article></body></html>`
gbkBytes, err := simplifiedchinese.GBK.NewEncoder().Bytes([]byte(utf8HTML))
if err != nil {
log.Fatal(err)
}
result, err := extractWithEncodingFallback(gbkBytes, "gbk")
if err != nil {
log.Fatal(err)
}
fmt.Printf("Title: %s\n", result.Title)
fmt.Printf("Text: %s\n", result.Text)
// Output:
// Auto-detection failed (encoding detection failed: ...), retrying with encoding "gbk"...
// Title: Test
// Text: Title Hello World
}TIP
Encoding detection retry is useful for processing HTML documents of unknown origin (e.g., legacy Chinese web pages scraped by a crawler). If your input source is fixed, simply specify the encoding in Config.Encoding — no retry logic needed.
Timeout Recovery
ErrProcessingTimeout indicates that processing time exceeded Config.ProcessingTimeout. The recovery strategy depends on the document characteristics:
| Strategy | Applicable scenario | Action |
|---|---|---|
| Reduce complexity | Complex document structure but simple content | Set ExtractArticle = false to skip article recognition |
| Extend timeout | Document is genuinely large and legitimate | Increase ProcessingTimeout |
| Simplify output | Only plain text needed | Use TextOnlyConfig() to disable all media extraction |
package main
import (
"errors"
"fmt"
"log"
"strings"
"time"
"github.com/cybergodev/html"
)
func main() {
// First attempt: standard config (30s timeout)
cfg := html.DefaultConfig()
p, err := html.New(cfg)
if err != nil {
log.Fatal(err)
}
defer p.Close()
largeHTML := []byte(`<html><body><article><h1>Large Document</h1><p>` +
strings.Repeat("content ", 100000) + `</p></article></body></html>`)
_, err = p.Extract(largeHTML)
if err != nil {
if errors.Is(err, html.ErrProcessingTimeout) {
fmt.Println("Standard config timed out, switching to simplified mode for retry...")
// Retry: disable article extraction + plain text mode + longer timeout
retryCfg := html.TextOnlyConfig()
retryCfg.ExtractArticle = false
retryCfg.ProcessingTimeout = 60 * time.Second
p2, err := html.New(retryCfg)
if err != nil {
log.Fatal(err)
}
defer p2.Close()
result, err := p2.Extract(largeHTML)
if err != nil {
log.Fatal(err)
}
fmt.Printf("Retry succeeded, extracted %d characters\n", len(result.Text))
} else {
log.Fatal(err)
}
}
}Input Too Large
ErrInputTooLarge indicates the input exceeded Config.MaxInputSize (default 50MB, which is also the maximum). Two handling approaches:
- Reduce input: If this is a web service, prompt the user to upload a smaller file
- Increase the limit: If your business genuinely requires large file processing, increase
MaxInputSize(maximum 50MB)
package main
import (
"errors"
"fmt"
"log"
"strings"
"github.com/cybergodev/html"
)
func main() {
cfg := html.DefaultConfig()
cfg.MaxInputSize = 1024 // 1KB limit (for demonstration)
p, err := html.New(cfg)
if err != nil {
log.Fatal(err)
}
defer p.Close()
// Construct input that exceeds the limit
largeInput := []byte(strings.Repeat("<div>", 500))
_, err = p.Extract(largeInput)
if err != nil {
var inputErr *html.InputError
if errors.As(err, &inputErr) {
fmt.Printf("Input %d bytes exceeds limit %d bytes\n", inputErr.Size, inputErr.MaxSize)
// Output: Input 2500 bytes exceeds limit 1024 bytes
}
}
}Error Wrapping Chain
All three structured error types (InputError, ConfigError, FileError) implement the Unwrap() method, supporting standard errors.Is() and errors.As() patterns. Understanding Unwrap() behavior is critical for correct error identification.
Unwrap Behavior Reference
| Type | Unwrap() return value | Description |
|---|---|---|
*InputError | InputErr (when non-nil) → otherwise ErrInputTooLarge | Exposes the underlying error if present; otherwise falls back to sentinel |
*ConfigError | Always ErrInvalidConfig | Fixed mapping to the config sentinel |
*FileError | ① If FileErr wraps ErrFileNotFound → ErrFileNotFound; ② otherwise if FileErr != nil → FileErr (original error); ③ otherwise → ErrInvalidFilePath | Three-level fallback: file not found → original error → invalid path |
Note
The three-level logic of FileError.Unwrap() means that errors from path traversal attacks (FileErr = "path traversal detected: ...") will not match any sentinel error — because Unwrap() returns the original path traversal error, not ErrFileNotFound or ErrInvalidFilePath. Detecting path traversal requires extracting FileError via errors.As and then checking the message.
Comprehensive Diagnosis Example
package main
import (
"errors"
"fmt"
"log"
"strings"
"github.com/cybergodev/html"
)
// diagnoseError uses errors.Is + errors.As for comprehensive error diagnosis.
func diagnoseError(err error) {
if err == nil {
fmt.Println("No error")
return
}
// 1. errors.Is: check sentinel errors (traverses the Unwrap chain)
fmt.Printf("errors.Is checks:\n")
fmt.Printf(" ErrInputTooLarge: %v\n", errors.Is(err, html.ErrInputTooLarge))
fmt.Printf(" ErrInvalidConfig: %v\n", errors.Is(err, html.ErrInvalidConfig))
fmt.Printf(" ErrFileNotFound: %v\n", errors.Is(err, html.ErrFileNotFound))
fmt.Printf(" ErrInvalidFilePath: %v\n", errors.Is(err, html.ErrInvalidFilePath))
// 2. errors.As: extract structured error types
var inputErr *html.InputError
if errors.As(err, &inputErr) {
fmt.Printf("InputError details: op=%s size=%d max=%d\n",
inputErr.Op, inputErr.Size, inputErr.MaxSize)
}
var configErr *html.ConfigError
if errors.As(err, &configErr) {
fmt.Printf("ConfigError details: field=%s value=%v message=%s\n",
configErr.Field, configErr.Value, configErr.Message)
}
var fileErr *html.FileError
if errors.As(err, &fileErr) {
fmt.Printf("FileError details: op=%s safePath=%s\n",
fileErr.Op, fileErr.SafePath())
// Detect path traversal (doesn't match sentinel, must check message)
if fileErr.FileErr != nil &&
strings.Contains(fileErr.FileErr.Error(), "path traversal") {
fmt.Println(" [Security Warning] Path traversal attack detected")
}
}
}
func main() {
cfg := html.DefaultConfig()
cfg.MaxInputSize = 100
p, err := html.New(cfg)
if err != nil {
log.Fatal(err)
}
defer p.Close()
// Scenario 1: Input too large → InputError → ErrInputTooLarge
fmt.Println("=== Scenario 1: Input too large ===")
_, err = p.Extract([]byte(strings.Repeat("x", 200)))
diagnoseError(err)
// Scenario 2: File not found → FileError → ErrFileNotFound
fmt.Println("\n=== Scenario 2: File not found ===")
_, err = p.ExtractFromFile("nonexistent.html")
diagnoseError(err)
// Scenario 3: Path traversal → FileError → doesn't match sentinel
fmt.Println("\n=== Scenario 3: Path traversal ===")
_, err = p.ExtractFromFile("../../etc/passwd")
diagnoseError(err)
}FileError Path Sanitization
FileError is designed with a built-in path information sanitization mechanism to prevent leaking server filesystem structure through error messages. This is especially important when handling file paths from untrusted users.
Sanitization Layers
| Layer | Method | Behavior |
|---|---|---|
| Error message | Error() | Calls SafePath() to show only the filename; calls sanitizeErrorMessage() to remove path details |
| Path truncation | SafePath() | Returns the basename of the path (e.g., /var/data/secret/page.html → page.html) |
| Error cleaning | sanitizeErrorMessage() | Preserves the error type (path traversal / not found / permission denied / access denied), removes path strings |
| JSON serialization | MarshalJSON() | Automatically sanitizes with SafePath(), suitable for HTTP API responses |
| Internal debugging | Path field | Retains the full path for logging and auditing (not exposed externally) |
Web Service Sanitization Example
package main
import (
"encoding/json"
"errors"
"fmt"
"log"
"net/http"
"github.com/cybergodev/html"
)
// apiResponse is the JSON structure returned to the client.
type apiResponse struct {
Error string `json:"error,omitempty"`
Message string `json:"message,omitempty"`
}
func extractHandler(w http.ResponseWriter, r *http.Request) {
filePath := r.URL.Query().Get("file")
if filePath == "" {
w.WriteHeader(http.StatusBadRequest)
json.NewEncoder(w).Encode(apiResponse{Error: "missing file parameter"})
return
}
cfg := html.DefaultConfig()
result, err := html.ExtractFromFile(filePath, cfg)
if err != nil {
// Error message is already sanitized — the client won't see server paths
w.WriteHeader(http.StatusUnprocessableEntity)
var fileErr *html.FileError
if errors.As(err, &fileErr) {
// MarshalJSON automatically sanitizes with SafePath(), safe to return to the client
fileErrJSON, _ := json.Marshal(fileErr)
fmt.Fprintf(w, `{"error":"file_error","detail":%s}`, fileErrJSON)
// Client sees: {"error":"file_error","detail":{"op":"ReadFile","path":"secret.html","message":"file not found"}}
// Instead of the full server path /var/www/uploads/secret.html
} else {
json.NewEncoder(w).Encode(apiResponse{Error: "extraction_failed"})
}
return
}
json.NewEncoder(w).Encode(result)
}
func main() {
// Demonstrate sanitization: simulate processing a nonexistent file path
cfg := html.DefaultConfig()
_, err := html.ExtractFromFile("/var/www/private/secret.html", cfg)
if err != nil {
var fileErr *html.FileError
if errors.As(err, &fileErr) {
fmt.Printf("Error() output (sanitized): %v\n", fileErr)
// Output: Error() output (sanitized): html: ReadFile "secret.html": file not found
fmt.Printf("SafePath(): %s\n", fileErr.SafePath())
// Output: SafePath(): secret.html
jsonBytes, _ := json.Marshal(fileErr)
fmt.Printf("MarshalJSON(): %s\n", jsonBytes)
// Output: MarshalJSON(): {"op":"ReadFile","path":"secret.html","message":"file not found"}
fmt.Printf("Path field (internal debugging): %s\n", fileErr.Path)
// Output: Path field (internal debugging): /var/www/private/secret.html
}
}
// Register the HTTP handler (register only, don't start the server)
http.HandleFunc("/extract", extractHandler)
fmt.Println("\nHandler registered, error messages auto-sanitized")
}TIP
MarshalJSON() allows FileError to be json.Marshal()'d and returned directly to HTTP clients without extra processing — path information is automatically sanitized during serialization. However, the Path field still retains the full path for internal logging and debugging only. Never return it directly to clients.
Web Service Error Mapping
In web services, you need to map library errors to appropriate HTTP status codes so clients can handle them correctly.
HTTP Status Code Mapping Table
| Sentinel Error | Suggested HTTP Status Code | Description |
|---|---|---|
ErrInputTooLarge | 413 Payload Too Large | Input exceeds limit; client should reduce input |
ErrInvalidHTML | 422 Unprocessable Entity | Unparseable HTML format |
ErrFileNotFound | 404 Not Found | File does not exist |
ErrInvalidFilePath | 400 Bad Request | Invalid path format |
ErrMaxDepthExceeded | 400 Bad Request | Possibly maliciously crafted deep nesting |
ErrProcessingTimeout | 504 Gateway Timeout | Processing timeout; client may retry later |
ErrProcessorClosed | 500 Internal Server Error | Programming error (incorrect lifecycle management) |
ErrInvalidConfig | 500 Internal Server Error | Programming error (config validation should be done at startup) |
ErrInternalPanic | 500 Internal Server Error | Internal bug; should be reported |
ErrMultipleConfigs | 500 Internal Server Error | Programming error (multiple Configs passed) |
Status Code Mapping Implementation
package main
import (
"errors"
"fmt"
"log"
"net/http"
"github.com/cybergodev/html"
)
// errorToHTTPStatus maps html library errors to appropriate HTTP status codes.
func errorToHTTPStatus(err error) int {
switch {
case errors.Is(err, html.ErrInputTooLarge):
return http.StatusRequestEntityTooLarge // 413
case errors.Is(err, html.ErrInvalidHTML):
return http.StatusUnprocessableEntity // 422
case errors.Is(err, html.ErrFileNotFound):
return http.StatusNotFound // 404
case errors.Is(err, html.ErrInvalidFilePath):
return http.StatusBadRequest // 400
case errors.Is(err, html.ErrMaxDepthExceeded):
return http.StatusBadRequest // 400
case errors.Is(err, html.ErrProcessingTimeout):
return http.StatusGatewayTimeout // 504
case errors.Is(err, html.ErrProcessorClosed):
return http.StatusInternalServerError // 500
case errors.Is(err, html.ErrInvalidConfig):
return http.StatusInternalServerError // 500
case errors.Is(err, html.ErrInternalPanic):
return http.StatusInternalServerError // 500
case errors.Is(err, html.ErrMultipleConfigs):
return http.StatusInternalServerError // 500
default:
return http.StatusInternalServerError // 500
}
}
func main() {
// Demonstrate HTTP status code mapping for various errors
// ErrInputTooLarge → 413
cfg := html.DefaultConfig()
cfg.MaxInputSize = 10
p, err := html.New(cfg)
if err != nil {
log.Fatal(err)
}
testCases := []struct {
name string
err error
}{
{"Input too large", func() error {
_, e := p.Extract(make([]byte, 100))
return e
}()},
{"File not found", func() error {
_, e := p.ExtractFromFile("missing.html")
return e
}()},
{"Processor closed", html.ErrProcessorClosed},
{"Internal panic", html.ErrInternalPanic},
}
for _, tc := range testCases {
if tc.err != nil {
fmt.Printf("%-16s → HTTP %d\n", tc.name, errorToHTTPStatus(tc.err))
}
}
// Output:
// Input too large → HTTP 413
// File not found → HTTP 404
// Processor closed → HTTP 500
// Internal panic → HTTP 500
p.Close()
}Error Decision Flowchart
When you encounter an error, determine its type in the following priority order (from most severe to least severe):
error != nil ?
│
├── errors.Is(err, ErrProcessorClosed)
│ → Programming error: check the timing of Close() calls, confirm the processor wasn't closed while in use
│
├── errors.Is(err, ErrInternalPanic)
│ → Internal bug: log the full stack trace and report; the input may have triggered an uncovered edge case
│
├── errors.As(err, &fileErr)
│ → File error: log the sanitized path with SafePath(), check FileErr for the specific cause
│ ├── errors.Is(err, ErrFileNotFound) → File does not exist
│ ├── Message contains "path traversal" → Security incident, audit log + reject
│ └── errors.Is(err, ErrInvalidFilePath) → Path format issue
│
├── errors.As(err, &inputErr)
│ → Input error: check Size/MaxSize, prompt user to reduce input or adjust limits
│
├── errors.Is(err, ErrProcessingTimeout)
│ → Timeout: consider simplifying processing (ExtractArticle=false) or retry with increased timeout
│
├── errors.Is(err, ErrMaxDepthExceeded)
│ → Possibly malicious: reject and record audit log
│
├── errors.Is(err, ErrInvalidHTML)
│ → Input format issue: prompt user to check the HTML source
│
├── errors.Is(err, ErrInvalidConfig)
│ → Config error: should be caught by Validate() at service startup; appearing at runtime indicates a logic error
│
└── Other
→ Unknown error: log the full error chain, propagate upward or return 500TIP
The checking order matters: ErrProcessorClosed and ErrInternalPanic should be checked first, as they represent programming errors or internal failures that need to be handled differently from input errors. The errors.As check for FileError should come after or alongside the errors.Is sentinel checks — because path traversal errors don't match any sentinel.
Structured Logging Practices
When logging errors with slog, you should extract fields from structured error types (rather than just logging the err.Error() string) for easier downstream log querying and alerting.
package main
import (
"errors"
"fmt"
"log/slog"
"strings"
"github.com/cybergodev/html"
)
// logExtractionError extracts structured fields based on error type and logs them via slog.
func logExtractionError(err error) {
var inputErr *html.InputError
var configErr *html.ConfigError
var fileErr *html.FileError
switch {
case errors.As(err, &inputErr):
// Input error: log Op/Size/MaxSize for troubleshooting capacity issues
slog.Warn("Extraction failed: input error",
"op", inputErr.Op,
"size", inputErr.Size,
"max_size", inputErr.MaxSize,
"sentinel", "ErrInputTooLarge",
)
case errors.As(err, &configErr):
// Config error: log Field/Value/Message for locating config issues
slog.Error("Extraction failed: config error",
"field", configErr.Field,
"value", configErr.Value,
"message", configErr.Message,
"sentinel", "ErrInvalidConfig",
)
case errors.As(err, &fileErr):
// File error: log sanitized path with SafePath(), check for path traversal
attrs := []any{
"op", fileErr.Op,
"path", fileErr.SafePath(), // Sanitized path to avoid leaking full paths in logs
}
if fileErr.FileErr != nil {
attrs = append(attrs, "cause", fileErr.FileErr.Error())
if strings.Contains(fileErr.FileErr.Error(), "path traversal") {
attrs = append(attrs, "security_event", "path_traversal")
}
}
slog.Warn("Extraction failed: file error", attrs...)
case errors.Is(err, html.ErrProcessingTimeout):
slog.Warn("Extraction failed: processing timeout", "err", err)
case errors.Is(err, html.ErrMaxDepthExceeded):
slog.Warn("Extraction failed: depth exceeded, possibly malicious", "err", err)
case errors.Is(err, html.ErrProcessorClosed):
slog.Error("Extraction failed: processor closed (programming error)", "err", err)
case errors.Is(err, html.ErrInternalPanic):
slog.Error("Extraction failed: internal panic, please report",
"err", err,
"issue", "https://github.com/cybergodev/html/issues",
)
default:
slog.Error("Extraction failed: unknown error", "err", err, "err_type", fmt.Sprintf("%T", err))
}
}
func main() {
cfg := html.DefaultConfig()
cfg.MaxInputSize = 100
p, err := html.New(cfg)
if err != nil {
slog.Error("Failed to create processor", "err", err)
return
}
defer p.Close()
// Scenario 1: Input too large → structured logging of Size/MaxSize
_, err = p.Extract([]byte(strings.Repeat("x", 200)))
if err != nil {
logExtractionError(err)
}
// Scenario 2: File not found → structured logging of SafePath
_, err = p.ExtractFromFile("/data/secret/missing.html")
if err != nil {
logExtractionError(err)
}
// Scenario 3: Path traversal → flag as security event
_, err = p.ExtractFromFile("../../../etc/passwd")
if err != nil {
logExtractionError(err)
}
}TIP
The key to structured logging is extracting fields rather than concatenating strings. For example, after logging inputErr.Size and inputErr.MaxSize, you can query your logging system for size > max_size * 0.9 to find requests approaching the limit and spot capacity issues early. For FileError, always use SafePath() instead of the Path field for logging — to prevent log files themselves from becoming an information leakage source.