SDK Go
A Go client for a backend request path: 150 ms budget, circuit breaker, fallback, async reports.
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#
go get github.com/payam-h/patterna-sdk/go@v0.4.10.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#
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())| Method | Key | Budget | Retried |
|---|---|---|---|
Recommend, RecommendWithFallback | serve | 150 ms | never (each call logs an impression) |
ReportInteractions (async), ReportInteractionsSync | serve | 5 s | once |
IngestEvents, UpsertItems, SetItemStatus, SetItemTags | ingest | 5 s | once |
SetUserMode, SetUserProfile, DeleteUser | admin if set, else ingest | 5 s | once |
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#
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.Cause | Meaning | What to do |
|---|---|---|
ErrTimeout, ErrNetwork, ErrCircuitOpen, status 5xx or 429 | Patterna could not answer | expected now and then; count it |
surface_off (409), no_candidates (422) | the surface is off, or nothing is left after the filters | expected 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 configuration | log at error level and alert; the fallback hides it from users, not from you |
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 isErrAborted.
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
ErrCircuitOpenfor 30 s. Then one probe call decides whether it closes. - A 4xx counts as the service being up.
OnBreakerChange(scope, open)andBreakerOpen(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:
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.
if errors.Is(err, patterna.ErrTimeout) {
// budget spent
}
if patterna.IsAPIError(err, "pii_rejected") {
// a field that may not leave your system was sent
}Consent#
- A request needs a mode.
non_profiledneeds 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_conflictif anon_profiledrequest 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.
RecommendsendsX-Deadline-Ms(Config.RecommendDeadline, default 120 ms, always below the 150 msRecommendTimeout). 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). WhenRecommendWithFallbackfalls back on a timeout, a 5xx, a network error or a bad answer, it reports the request id withAbandon(requestID, reason)in the background. The engine flags that slateclient_abandoned(the answer is200withrequest_id,arm,fallback: true,client_abandoned: true; repeating it is harmless): it stays in its arm and counts in the fallback rate. CallAbandon(id, patterna.AbandonClientFallback)yourself when you decide not to show a slate you received;AbandonSyncwaits 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, forIsAPIError. - Kind filled in. The client remembers each slate's item kinds, so a report without
Kindgets the served kind. The engine takeskindand the served position from the logged slate anyway, refuses item ids that were not in the slate (unknown_item), and accepts an optionalBlockfor one slate rendered in two blocks. - New request fields.
AssignmentUnitwith the helperpatterna.AssignmentUnit(secret, userID)(64 hex characters;non_profiledrequests only; keeps that person in one holdout arm, see Identity across connectors);Context.Measured/SetMeasured(analyticsConsent)(see Consent modes);WithTester()sendsX-Patterna-Tester: 1, so your own staff roster decides who sees a surface in statustesters. - Catalog.
GetItem(ctx, kind, id)reads one item with itsAttributes,Tags,StatusandEnrichment(ingest key;ErrItemNotFoundwhen there is none under that id and kind),ListItems,ItemsChangedSince(ctx, since, kind, fn)for a nightly reconcile, andDeleteItem(ctx, kind, id). - Signing secret.
CreateSigningSecretfetches the first secret on a tenant that has none. - Admin package and CLI.
sdk-go/admincovers surfaces (status, testers), flows (create, activate, rollback,ApplyFlow), experiments (start, end), the ledger and its export, and the signing secret. ThepatternaCLI wraps it. Keep flow configs in your repository and apply them from CI with an admin key that never sits in the request path:
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()(andrec.ShownonRecommendWithFallback) is false when the slate came backshown: false(fewer items than the surface'smin_slots). Hide the block and do not callAbandon: the engine has already logged the slate as not shown (Abandonwould get409 not_shown). CallAbandononly 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). Ashown: falseslate 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) andRecommendContext.MemberOf(communities the user joined, left out in every mode). See Backend API. - Fill and source.
Slate.FillCount,SlateItem.FillandSlate.ControlSourcesay 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_tierandtenure_days. SDK 0.4.1'sRecommendContexthas typed fields for them, next to typed catalog attributes, event builders, the flow filters and generators of engine 0.3.1 and the reason keyrecommended. 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.gois hand-written; change it with the contract. - A metrics library.
OnBreakerChange,DroppedReportsandRecommendation.Causegive 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 throughReportInteractions, each withkindand the 0-basedposition, plusdisplayedPositiononly 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_URLis 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
UpsertItemsandSetItemStatus, 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
DeleteUserfirst. Failures go to a pending-deletions table that holds only the random id, and a loop retries. - Environment.
| Variable | Value |
|---|---|
PATTERNA_URL | https://patterna-api.orven.am/v1; empty disables Patterna |
PATTERNA_SERVE_KEY | a serve key, pk_s_… |
PATTERNA_INGEST_KEY | an ingest key, pk_i_…, also used for consent mode and deletion |
PATTERNA_TIMEOUT_MS | 150 |