Patterna Docs

SDK Go

A Go client for a backend request path: 150 ms budget, circuit breaker, fallback, async reports.

Developers 8 min read View as Markdown

github.com/payam-h/patterna-sdk/go is the Go client. It is built for a customer backend that calls Patterna inside a user request. Standard library only, Go 1.25, no transitive dependencies.

Install#

Shell
go get github.com/payam-h/patterna-sdk/go@v0.4.1

0.4.1 is the current release, published from the public repository payam-h/patterna-sdk (MIT). The module lives in its go/ subdirectory, so the git tag is go/v0.4.1; go get …@v0.4.1 resolves it through proxy.golang.org. Pin a version, never latest. It works with engine 0.3.1 (engine_version on /health).

Client#

Go
pc, err := patterna.New(patterna.Config{
	BaseURL:   os.Getenv("PATTERNA_URL"),        // https://patterna-api.orven.am/v1
	ServeKey:  os.Getenv("PATTERNA_SERVE_KEY"),
	IngestKey: os.Getenv("PATTERNA_INGEST_KEY"),
	Logger:    slog.Default(),
})
if err != nil {
	return err
}
defer pc.Close(context.Background())
MethodKeyBudgetRetried
Recommend, RecommendWithFallbackserve150 msnever (each call logs an impression)
ReportInteractions (async), ReportInteractionsSyncserve5 sonce
IngestEvents, UpsertItems, SetItemStatus, SetItemTagsingest5 sonce
SetUserMode, SetUserProfile, DeleteUseradmin if set, else ingest5 sonce

The engine accepts the ingest key for the three user calls, so configure only ServeKey and IngestKey on a backend. Keys start with pk_s_ (serve), pk_i_ (ingest) and pk_a_ (admin).

Recommend with a fallback#

Go
rec, err := pc.RecommendWithFallback(ctx, patterna.RecommendRequest{
	Surface: "home_articles",
	Mode:    patterna.ModePersonalized,
	UserID:  patternaID,                         // the random id you minted, never an email or auth0|...
	Context: patterna.RecommendContext{Locale: "nl", Platform: "ios"},
}, newestArticleIDs)
if err != nil {
	return err // consent or input bug: never hidden behind the fallback
}
// rec.ItemIDs is always set (empty when rec.Shown is false); rec.Slate is nil when the fallback was used.

if rec.Slate != nil && rec.Slate.Visible() { // a fallback list has no request id; shown:false means hide the block
	it := rec.Slate.Items[0]
	_ = pc.ReportInteractions(rec.Slate.RequestID, []patterna.Interaction{{
		ItemID:   it.ItemID,
		Kind:     it.Kind,     // the engine takes kind and position from the logged slate anyway
		Position: it.Position, // 0-based slot as served
		Type:     patterna.InteractionSeen,
	}}) // fire-and-forget
}

newestArticleIDs is your current query. rec.Cause says why it ran. RecommendWithFallback falls back on timeouts, network errors, an open breaker and every API error answer except three: consent violations, pii_rejected and mode_conflict come back as errors, never as a fallback, because a consent bug is a privacy bug. Read rec.Cause and treat the codes differently:

rec.CauseMeaningWhat to do
ErrTimeout, ErrNetwork, ErrCircuitOpen, status 5xx or 429Patterna could not answerexpected now and then; count it
surface_off (409), no_candidates (422)the surface is off, or nothing is left after the filtersexpected while a surface is off; otherwise fix the catalog or the flow
bad_request (400), unauthorized/wrong_scope (401), unknown_surface (404)a bug in your call or configurationlog at error level and alert; the fallback hides it from users, not from you
Go
var pe *patterna.Error
if rec.Fallback && errors.As(rec.Cause, &pe) && !pe.Transient() &&
	!patterna.IsAPIError(rec.Cause, "surface_off") && !patterna.IsAPIError(rec.Cause, "no_candidates") {
	slog.Error("patterna call is broken", "code", pe.Code, "request_id", pe.RequestID)
}

Log rec.Slate.Arm (treatment, control or none) next to your traces when a slate came back.

Timeouts#

  • Each call runs under context.WithTimeout(ctx, budget). A shorter deadline from the caller wins.
  • The budget covers the retry too.
  • A deadline is ErrTimeout. A cancel by the caller is ErrAborted.

Leave the serve budget at 150 ms. Patterna answers in 60 ms p99 from cache and 250 ms live; above 150 ms your own list is the better answer.

The engine's own budget is 120 ms by default, and the SDK asks it to answer within X-Deadline-Ms (Config.RecommendDeadline, 120 ms by default), so its answer arrives before the SDK gives up. When the SDK gives up anyway, it reports the abandoned request id. Details: Timeouts and the ledger.

Retry#

One retry after 100 ms, on 5xx and network errors, for idempotent calls only. Recommend is never retried.

Circuit breaker#

  • One breaker per scope (serve, ingest).
  • It opens after 5 consecutive failures: timeouts, network errors, 5xx and 429.
  • While open it fails fast with ErrCircuitOpen for 30 s. Then one probe call decides whether it closes.
  • A 4xx counts as the service being up.
  • OnBreakerChange(scope, open) and BreakerOpen(scope) feed your metrics and health checks.

Fire-and-forget reports#

ReportInteractions puts the report on a bounded queue (1024) served by 2 workers and returns at once. A full queue returns ErrQueueFull and counts in DroppedReports(). Close(ctx) drains the queue, so call it on shutdown:

Go
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
_ = pc.Close(ctx) // drains queued interaction reports
cancel()

Use ReportInteractionsSync when you need the result.

Errors#

Every error is *Error{Code, Op, Status, RequestID, Message, Details}. API errors carry the API's code (pii_rejected, mode_conflict, no_candidates, surface_off, ...); the list is in Error codes. Transient() is true for timeouts, network errors, an open breaker, 5xx and 429.

Go
if errors.Is(err, patterna.ErrTimeout) {
	// budget spent
}
if patterna.IsAPIError(err, "pii_rejected") {
	// a field that may not leave your system was sent
}
  • A request needs a mode. non_profiled needs a session id and no user id.
  • User ids that look like an email or an identity-provider subject are refused everywhere, before anything is sent, and never appear in error text.
  • The engine answers 422 mode_conflict if a non_profiled request still carries a user id.

See Consent modes.

Signing#

SignEvent(e, secret string) signs with the same canonical JSON and HMAC-SHA256 as the TypeScript and Python SDKs. The key is the pss_ secret string itself. Since engine 0.2 secrets are pss_ strings; a secret from before that (64 hex characters, no prefix) no longer verifies, so rotate it once. Events sent with an ingest key from your backend are trusted without a signature; sign only when an event passed an untrusted hop. Test vector and rules: Backend API.

Deadline, abandonment and the rest of the client#

  • Deadline. Recommend sends X-Deadline-Ms (Config.RecommendDeadline, default 120 ms, always below the 150 ms RecommendTimeout). The engine answers within it or serves the fallback list, so its answer arrives before the SDK gives up.
  • Abandon. The SDK mints the request id (X-Request-Id). When RecommendWithFallback falls back on a timeout, a 5xx, a network error or a bad answer, it reports the request id with Abandon(requestID, reason) in the background. The engine flags that slate client_abandoned (the answer is 200 with request_id, arm, fallback: true, client_abandoned: true; repeating it is harmless): it stays in its arm and counts in the fallback rate. Call Abandon(id, patterna.AbandonClientFallback) yourself when you decide not to show a slate you received; AbandonSync waits for the answer.
  • RecommendMany(ctx, []ManyRequest). Several surfaces for one screen (for example Home articles and Home products), each with its own fallback, run concurrently under one deadline. Each slate has its own request id and impression.
  • Typed API codes. CodeNoCandidates, CodeSurfaceOff, CodeUnknownSurface, CodeUnknownRequest, CodePIIRejected, CodeModeConflict, for IsAPIError.
  • Kind filled in. The client remembers each slate's item kinds, so a report without Kind gets the served kind. The engine takes kind and the served position from the logged slate anyway, refuses item ids that were not in the slate (unknown_item), and accepts an optional Block for one slate rendered in two blocks.
  • New request fields. AssignmentUnit with the helper patterna.AssignmentUnit(secret, userID) (64 hex characters; non_profiled requests only; keeps that person in one holdout arm, see Identity across connectors); Context.Measured / SetMeasured(analyticsConsent) (see Consent modes); WithTester() sends X-Patterna-Tester: 1, so your own staff roster decides who sees a surface in status testers.
  • Catalog. GetItem(ctx, kind, id) reads one item with its Attributes, Tags, Status and Enrichment (ingest key; ErrItemNotFound when there is none under that id and kind), ListItems, ItemsChangedSince(ctx, since, kind, fn) for a nightly reconcile, and DeleteItem(ctx, kind, id).
  • Signing secret. CreateSigningSecret fetches the first secret on a tenant that has none.
  • Admin package and CLI. sdk-go/admin covers surfaces (status, testers), flows (create, activate, rollback, ApplyFlow), experiments (start, end), the ledger and its export, and the signing secret. The patterna CLI wraps it. Keep flow configs in your repository and apply them from CI with an admin key that never sits in the request path:
Go
pc, _ := patterna.New(patterna.Config{BaseURL: os.Getenv("PATTERNA_URL"), AdminKey: os.Getenv("PATTERNA_ADMIN_KEY")})
adm := admin.New(pc)
cfg, _ := os.ReadFile("flows/more_to_read.json")
res, err := adm.ApplyFlow(ctx, "more_to_read", cfg, "weights from the September review", true) // true: activate
if err != nil {
	return err
}
fmt.Println(res)

ApplyFlow creates a new version only when the config differs from the active one. The CLI does not create surfaces: do that in the panel or with POST /surfaces. patterna surface apply -f surfaces/<key>.json [--dry-run] changes an existing surface's item_kinds, slots, min_slots, fallback, device_resort, notes and exploration_share, patching only the fields that differ (the fallback is replaced as a whole). It never changes the status.

Shown, control lists and the context fields#

  • Slate.Visible() (and rec.Shown on RecommendWithFallback) is false when the slate came back shown: false (fewer items than the surface's min_slots). Hide the block and do not call Abandon: the engine has already logged the slate as not shown (Abandon would get 409 not_shown). Call Abandon only for a slate that came back shown and that you then did not show (for example when your hydration dropped items below what the block needs). A shown: false slate carries no items, and it stays in its arm counted as served with zero taps.
  • Control lists and communities. RecommendContext.ControlItemIDs (your own editorial list for the page, served to the control arm) and RecommendContext.MemberOf (communities the user joined, left out in every mode). See Backend API.
  • Fill and source. Slate.FillCount, SlateItem.Fill and Slate.ControlSource say which positions came from the editorial list and where an editorial slate came from.
  • Context fields. The engine reads weekday (0 = Monday, from the device clock), subscription_tier and tenure_days. SDK 0.4.1's RecommendContext has typed fields for them, next to typed catalog attributes, event builders, the flow filters and generators of engine 0.3.1 and the reason key recommended. What each field does: What the engine uses, and what it refuses.

Left out on purpose#

  • Proof and ledger reads. The panel and the admin API own them.
  • Generated types. types.go is hand-written; change it with the contract.
  • A metrics library. OnBreakerChange, DroppedReports and Recommendation.Cause give you the numbers.

A backend drop-in, in short#

The shape that works for a Go backend (for example Gin + gqlgen):

  • One query, one mutation. recommendations(surface, context) returns slots with reason text and a request id. reportRecommendation(requestId, interactions) forwards seen, click and dismiss through ReportInteractions, each with kind and the 0-based position, plus displayedPosition only where the app shows an item somewhere other than its served slot.
  • A feature flag. Off means your current query. It is the instant rollback; an empty PATTERNA_URL is the kill switch.
  • Your consent records decide the mode, with one mapping table used everywhere: see Consent modes. A consent change calls SetUserMode. Reporting follows the analytics consent.
  • A mapping table. Mint a random id (u_ + 128 random bits) per user on first use. Your identity-provider subject and the email never leave your system. Revoking consent rotates the id. See Identity across connectors.
  • Hydration stays with you. Patterna returns ids; you load the items yourself, so premium redaction, locale and status stay yours. Hide a block when hydration leaves fewer items than the block needs.
  • Catalog notifier. Upsert, archive and schedule call UpsertItems and SetItemStatus, detached, never failing the editor's action. A backfill command loads the catalog once and a nightly reconcile catches lost calls.
  • Deletion eraser. Account deletion calls DeleteUser first. Failures go to a pending-deletions table that holds only the random id, and a loop retries.
  • Environment.
VariableValue
PATTERNA_URLhttps://patterna-api.orven.am/v1; empty disables Patterna
PATTERNA_SERVE_KEYa serve key, pk_s_…
PATTERNA_INGEST_KEYan ingest key, pk_i_…, also used for consent mode and deletion
PATTERNA_TIMEOUT_MS150
esc
  • Type to search every page, heading and code sample.