Observability
The SDK exposes two lifecycle hooks on client construction, option.WithOnRetry(func(polipage.RetryEvent)) and option.WithOnError(func(error)). They run synchronously inside the SDK call, which makes them the place to count retries, record a request ID or emit structured log lines. The Gin integration adds no event bus of its own: Go already has slog, OpenTelemetry and metrics libraries that compose with plain functions.
The callbacks
Section titled “The callbacks”option.WithOnRetryfires before each retry sleep.RetryEventhasAttempt(the attempt about to run, so2for the first retry),DelayMsandReason.Reasonis anerrorthat is a*polipage.Errorat runtime. The first HTTP call (Attempt1) fires no event.option.WithOnErrorfires once at terminal failure: retries exhausted, a non-retryable error, or cancellation. The argument is the*polipage.Errorthe call returns.- Panics inside a callback are recovered by the SDK, so a buggy hook never breaks the request.
- Both options work on
polipage.NewClientand per call, as an extra argument toRender.PDFand the other methods. A per-call hook leaves the shared client untouched.
option.WithLogger takes a *slog.Logger for the SDK’s own lines. The callbacks are where you add a custom shape.
Structured logging with slog
Section titled “Structured logging with slog”logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
client := polipage.NewClient( option.WithAPIKey(os.Getenv("POLI_PAGE_API_KEY")), option.WithLogger(logger), option.WithOnRetry(func(e polipage.RetryEvent) { attrs := []slog.Attr{ slog.Int("attempt", e.Attempt), slog.Float64("delay_ms", e.DelayMs), } var pe *polipage.Error if errors.As(e.Reason, &pe) { attrs = append(attrs, slog.String("reason_code", pe.Code)) } logger.LogAttrs(context.Background(), slog.LevelWarn, "polipage.retry", attrs...) }), option.WithOnError(func(err error) { var pe *polipage.Error if errors.As(err, &pe) { logger.Error("polipage.error", "code", pe.Code, "status", pe.StatusCode, "request_id", pe.RequestID, "message", pe.Message) return } logger.Error("polipage.error", "err", err) }),)A per-request audit trail
Section titled “A per-request audit trail”Collect the events of one render in a value the handler owns, and hand it to your logging middleware after c.Next(). Do not close over *gin.Context in a callback: Gin pools contexts and recycles them after the handler returns, so a goroutine that touches c later reads the next request’s state. When a callback must do async work, capture c.Copy() instead of c.
type renderTrail struct { mu sync.Mutex events []renderEvent}
type renderEvent struct { Attempt int DelayMs float64 Reason error // a *polipage.Error at runtime; nil for terminal-error rows Err error}
func (t *renderTrail) push(e renderEvent) { t.mu.Lock() t.events = append(t.events, e) t.mu.Unlock()}
func renderHandler(c *gin.Context) { client := polipagegin.ClientFrom(c) trail := &renderTrail{}
pdf, err := client.Render.PDF(c.Request.Context(), input, option.WithOnRetry(func(e polipage.RetryEvent) { trail.push(renderEvent{Attempt: e.Attempt, DelayMs: e.DelayMs, Reason: e.Reason}) }), option.WithOnError(func(err error) { trail.push(renderEvent{Err: err}) }), ) c.Set("polipage.trail", trail.events) // read after c.Next() by a logging middleware
if err != nil { polipagegin.WriteError(c, err) return } polipagegin.PDF(c, pdf, polipagegin.PDFOptions{Filename: "invoice.pdf"})}Prometheus counters
Section titled “Prometheus counters”Label with the *polipage.Error code, a bounded set (VALIDATION_ERROR, QUOTA_EXCEEDED, network_error, and so on). Never label by err.Error(), e.DelayMs or pe.RequestID: unbounded labels overload the time-series database.
var ( renderRetries = promauto.NewCounterVec(prometheus.CounterOpts{ Name: "polipage_render_retries_total", Help: "Total retries emitted by the Poli Page SDK.", }, []string{"reason"})
renderErrors = promauto.NewCounterVec(prometheus.CounterOpts{ Name: "polipage_render_errors_total", Help: "Total terminal errors emitted by the Poli Page SDK.", }, []string{"code"}))
client := polipage.NewClient( option.WithAPIKey(os.Getenv("POLI_PAGE_API_KEY")), option.WithOnRetry(func(e polipage.RetryEvent) { reason := "unknown" var pe *polipage.Error if errors.As(e.Reason, &pe) { reason = pe.Code } renderRetries.WithLabelValues(reason).Inc() }), option.WithOnError(func(err error) { code := "unknown" var pe *polipage.Error if errors.As(err, &pe) { code = pe.Code } renderErrors.WithLabelValues(code).Inc() }),)Gotchas
Section titled “Gotchas”- Callbacks run synchronously. Heavy work in a callback, such as a database write or an HTTP call, blocks the render that triggered it. Emit and return, and push slow work to a worker pool or a buffered channel.
OnErrorfires for every terminal failure, including the ones you will answer as4xx. Filter onpe.IsRetryable()for server-side issues only, or onpe.IsAuthError()for misconfigurations.e.Reasonis anerror, not a string. Extract.Codewitherrors.As. A type assertion tostringpanics.- The SDK does not retry on
context.Canceled. Cancelc.Request.Context()and you getpolipage.ErrAbortedwith noOnRetryevent. - A panic is not a stop signal. Use
option.WithMaxRetries(0)to turn retries off.
Example
Section titled “Example”logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
client := polipage.NewClient( option.WithAPIKey(os.Getenv("POLI_PAGE_API_KEY")), option.WithLogger(logger), option.WithOnError(func(err error) { logger.Error("polipage.error", "err", err) }),)
r := gin.Default()r.Use(polipagegin.Middleware(client))