Your SDK writes an audit record from a feature hook, and the records are missing for exactly the
calls a reviewer asks about: the ones the client refused. The hook runs at PreDone, which a call
refused at PrePoint never reaches. The client reported the refusal correctly, which is why nobody
noticed the gap.
What you get
You will end up with four behaviors, each placed at the stage that holds its input, and tests that force each kind of failure and assert which stages ran. This is for you if you add features to an sdkgen client.
Short answer
Put each behavior at the earliest sdkgen stage that holds what it needs. That is PrePoint for a
permission check, PreRequest for a request header, PreResult for reshaping, and PreDone plus
PreUnexpected for an audit record. PreDone runs after an HTTP error or a refused connection, but
not after a refusal at PrePoint or a hook that throws. PreUnexpected covers those and can fire
twice, so write the record once per call.
You will need
Node 22 or later, Ruby 3.2 or later, and an SDK generated by sdkgen. If you have not written a hook
before, add one PreRequest hook to every operation first. Verified 2026-10-15 against Node
22.22.0, Ruby 3.3.6, @voxgig/create-sdkgen 0.30.7 and @voxgig/sdkgen 4.34.7. The sample’s
TypeScript and Ruby targets were generated once from api.json by generate.sh and committed, so
every command runs offline. The stage list is in sdkgen’s
hooks reference.
Voxgig maintains sdkgen. This page compares its operation pipeline with Speakeasy’s SDK hooks and Faraday’s middleware.
Approaches compared
| Approach | When it fits | What it costs you | When to pick something else |
|---|---|---|---|
| Faraday request and response middleware | A hand-written Ruby client where one class should see the request and the response | One stack under every call, so a check that needs the operation name infers it from the URL | The client is generated and the behavior must exist in every target |
| sdkgen six-stage operation pipeline | A generated client where a behavior needs the operation, the spec or the parsed body | Seven hooks to choose between, and a PreDone that a refusal at PrePoint never reaches | The behavior acts on each HTTP attempt, which belongs at the transport |
| Speakeasy SDK hooks | A Speakeasy SDK where request and response hooks are enough | Success and failure arrive in different hooks, and no hook runs after the body is parsed | You reshape parsed results, or need one hook that sees every outcome |
Speakeasy splits the end of a call in two: afterSuccess sees a good response before its body is
parsed, and afterError sees a failed one.1 Faraday gives one class an on_request and an
on_complete, and its source adds an on_error.2 sdkgen has a stage for each step between the
operation and the entity data. That precision is what this page uses, at a price: every hook you
add runs on every call.
Read what each stage holds
Each stage fires before its step runs. The Ruby target’s pipeline shows the shape: a hook, a step, and an early return when the step returns an error.
utility.feature_hook.call(ctx, "PrePoint")
point, err = utility.make_point.call(ctx)
ctx.out["point"] = point
return utility.make_error.call(ctx, err) if err
utility.feature_hook.call(ctx, "PreSpec")
spec, err = utility.make_spec.call(ctx)
ctx.out["spec"] = spec
return utility.make_error.call(ctx, err) if err
utility.feature_hook.call(ctx, "PreRequest")
The early return is the path a hook’s refusal takes. A failure inside a step, such as an HTTP
error, a refused connection, or a missing path parameter, is recorded in the result instead, and
the call carries on to PreDone. The TypeScript tests pin all three.
A test probes the TypeScript client at every stage of one load, and pins this table:
| Stage | Holds | Not yet |
|---|---|---|
PrePoint | The operation and entity names | An endpoint or a spec |
PreSpec | The resolved endpoint in ctx.out.point | A spec: ctx.spec is undefined |
PreRequest | The spec: method, path, parameters, headers | The fetch request in ctx.out.request |
PreResponse | The response status and headers | A parsed body |
PreResult | The parsed body in ctx.result.body | Entity data |
PreDone | ctx.result.resdata, entity instances for a list | The caller’s return value |
Place the four behaviors
The rule is the earliest stage that holds the input. A permission check needs only the operation,
so it runs at PrePoint, before any work. A header needs a spec to live in, so it runs at
PreRequest. Reshaping needs the parsed body before it becomes entities, so PreResult. An audit
record needs the outcome, which no single stage always sees.
export function permissions({ granted = [], rules = {} } = {}) {
return {
name: 'permissions',
PrePoint(ctx) {
const need = rules[`${ctx.entity?.name}.${ctx.op?.name}`]
if (need === undefined || granted.includes(need)) return
const err = ctx.error('permission_denied', `needs "${need}" for ${ctx.entity?.name}.${ctx.op?.name}`)
ctx.out.point = err
return err
},
}
}
Setting ctx.out.point to an error is how a PrePoint hook refuses, the same move sdkgen’s own
rbac feature makes. Each feature is a plain object passed in the client’s extend option, and the
runtime calls its method named after the stage. The demo runs one client with all four against a
local server:
node demo.mjs
invoice.load inv_1: ok, 12.50
stages PrePoint PreSpec PreRequest PreResponse PreResult PreDone
invoice.list: ok, 12.50 0.99
stages PrePoint PreSpec PreRequest PreResponse PreResult PreDone
invoice.load inv_broken: LedgerSDK: load: request: 500: Internal Server Error
stages PrePoint PreSpec PreRequest PreResponse PreResult PreDone PreUnexpected PreUnexpected
invoice.remove inv_2: LedgerSDK: remove: needs "invoices:delete" for invoice.remove
stages PrePoint PreUnexpected PreUnexpected
invoice.load, nothing listening: LedgerSDK: load: fetch failed
stages PrePoint PreSpec PreRequest PreResponse PreResult PreDone PreUnexpected PreUnexpected
requests the server saw
GET /v1/invoices/inv_1 x-request-id: req_1
GET /v1/invoices x-request-id: req_2
GET /v1/invoices/inv_broken x-request-id: req_3
audit records, one per call
invoice.load ok status 200
invoice.list ok status 200
invoice.load error status 500 request_status
invoice.remove error status null permission_denied
invoice.load error status -1 ECONNREFUSED
The 500 and the dead port both reach PreDone. The refused remove stops after PrePoint, sends
nothing, and goes straight to PreUnexpected, which fires twice: once from the error helper and
once from the operation’s own catch.3 The hooks reference describes PreUnexpected as firing
when an exception escapes the pipeline. In 4.34.7 it fires for every failed call, including the
500 and the dead port, because the error helper fires it before raising. The reshape added amount
to both reads and left the error alone.
Record every call exactly once
The same five calls, with the audit record written from four places:
node count.mjs
5 calls: 2 succeed, 1 HTTP 500, 1 refused at PrePoint, 1 with nothing listening
PreResult only 4 records no record of the refused remove
PreDone only 4 records no record of the refused remove
PreDone and PreUnexpected, unguarded 10 records
PreDone and PreUnexpected, guarded 5 records
PreResult and PreDone miss the same call. Adding PreUnexpected without a guard writes ten
records for five calls. The guard is a
WeakSet
of the call’s context object, which lets each context be collected once the call is over. sdkgen’s
shipped audit feature uses one for the same reason.
Check it worked
Thirteen TypeScript tests force a 500, a refusal, a dead port, a missing path parameter, and a hook
that throws. One passes the same ctrl object to two calls. The SDK keeps a call’s error on that
object, so an audit that read the error from there recorded the first call’s failure against the
second call’s success.
node --test stages.test.mjs
1..13
# tests 13
# suites 0
# pass 13
# fail 0
# cancelled 0
# skipped 0
# todo 0
The Ruby target runs the same stages in the same order, and seven minitest tests pin them, a
refused connection included. Its audit guard is an
ObjectSpace::WeakMap, which
compares keys by identity and holds them weakly. A Set would keep every context the client ever
audited, and a test counts the contexts still alive after forty calls.
ruby test.rb
7 runs, 20 assertions, 0 failures, 0 errors, 0 skips
When it goes wrong
The feature never runs. The generated README and sdkgen’s
pipeline explanation
both describe a feature as an object with a hooks map. The 4.34.7 runtime calls a method named
after the stage on the feature itself and never reads the map, as a test pins. Put the methods on
the object.
The caller gets your TypeError instead of the SDK’s error. A hook threw, and the pipeline
treated it as the failure: PreDone was skipped and PreUnexpected fired once. Guard reshaping
code with ctx.result.ok, because a failed call reaches PreResult carrying its error body.
Setting a header at PreSpec throws, because ctx.spec does not exist yet. Move it to
PreRequest.
A request id minted in a Speakeasy beforeRequest hook changes on every retry. Mistral’s
Speakeasy-generated TypeScript client calls beforeRequest and the after hooks inside its
retry loop, once per attempt.
sdkgen’s hooks reference says a hook handles one operation while the transport handles every
attempt, so an id set at PreRequest holds across the attempts.
When not to do this
Do not treat a PrePoint permission check as a security boundary. It saves a round trip and gives
a clear error, but the caller can construct the client without it, so the server must still
enforce the rule.
Do not put per-attempt work in a stage hook. Timing each attempt, retrying, or answering from a
cache belongs at sdkgen’s transport seam, a feature declared with transport: 'wrap', which the
hooks reference says sees every HTTP attempt.
Do not add a hook for a behavior that one call site needs. Every feature in extend runs on every
operation of the client, and one that waits on the network adds that wait to each call.
Related how-tos
Last verified
Verified 2026-10-15 against Node 22.22.0, Ruby 3.3.6, @voxgig/create-sdkgen 0.30.7 and
@voxgig/sdkgen 4.34.7. Every output block is what the command preceding it printed. Speakeasy and
Faraday were not run; their hooks are taken from the documentation and source linked on this page.
Footnotes
-
Speakeasy’s hooks page lists four lifecycle events: initialization, before request, after success, and after error. The
types.tsin a published Speakeasy-generated SDK declares five hook interfaces. The fifth,beforeCreateRequest, runs before theRequestobject exists, because its own comment explains that some changes, such as the URL, cannot be made to a request once it is built. ↩︎ Back to text -
The custom middleware guide says a subclass need only implement two methods,
on_requestandon_complete. Thecallmethod in the source checks for a third,on_error, and calls it with the exception before raising it again. ↩︎ Back to text -
The double call is deliberate on one side. sdkgen 4.34.7’s error helper fires
PreUnexpectedto close error paths that never reachPreDone, anrbacshort-circuit being its own example. The shipped audit feature then keeps aWeakSetso that one operation is not logged twice, which is the same arrangement seen from the receiving end. ↩︎ Back to text