Your service calls a partner API through a generated SDK, and the machine token is your application’s problem. A helper fetches it at startup, a scheduled job renews it, and three services copied the helper with three different expiry checks. One copy renews on every call, so the authorization server rate-limits your client before the API ever does.
What you get
You will end up with a generated client that fetches its machine token, reuses it until a margin before expiry, and renews it inside the retry loop. A test counts the token requests. This is for you if application code mints the SDK’s token.
Short answer
Make the generated SDK’s transport fetch, cache, and renew the machine token, so application
code never handles it. Speakeasy generates the exchange from an oauth2 scheme with a
tokenUrl, and OpenAPI Generator’s Java client ships a RetryingOAuth interceptor. With sdkgen
you write the feature: it wraps the transport, caches the token until a margin before
expires_in, and buys one replacement on a 401. Initialize it before retry, so every retried
attempt carries a current token.
You will need
Node 22 or later, Go 1.24 or later, an OpenAPI 3 document whose security scheme is oauth2 with
a clientCredentials flow and a tokenUrl, and a client id and secret registered at that
endpoint. Verified 2026-10-21 against Node 22.22.0, Go 1.24.7, @voxgig/create-sdkgen 0.30.8,
@voxgig/sdkgen 4.35.0, and golang.org/x/oauth2 v0.35.0. The sample’s TypeScript SDK was
generated once by generate.sh and its compiled output is committed under generated/ts, so
every command runs offline against an in-process stand-in. The grant is section 4.4
of RFC 6749; the sample’s token
endpoint takes client_secret_basic, the client authentication the RFC requires every server to
support.1
Approaches compared
| Approach | When it fits | What it costs you | When to pick something else |
|---|---|---|---|
| Hand-written transport wrapper | Any client that takes an HTTP client or a fetch function, in a language with a library such as golang.org/x/oauth2 | A wrapper per language and per client to keep in step, and an expiry rule you own | The generator already fetches the token for every language you ship |
| OpenAPI Generator | A Java client on the default okhttp-gson library, whose RetryingOAuth interceptor runs the grant | Renewal only after a 401 or 403, with expires_in never read, so every token lifetime ends with one refused request | You generate another language, or one refused request per token lifetime is too many |
| sdkgen custom feature | An sdkgen project where the token policy should be yours and should run at the transport seam | A model file and a source file per target, written and tested by you, because no shipped feature runs this grant | The generator you use already manages the token, or you ship many sdkgen targets |
| Speakeasy | A Speakeasy SDK whose document declares oauth2 with clientCredentials and a tokenUrl | A fixed policy: client_secret_post by default, a sixty-second expiry buffer, and a 401 that fails the call before the next one renews | You need another client authentication in a language the docs mark unsupported, or a renewal that retries the same call |
Voxgig maintains sdkgen. It is one of four options here, not the recommendation.
Speakeasy and OpenAPI Generator decide the policy for you. The Speakeasy hook treats a token as expired sixty seconds early, and the Java interceptor keeps one until the server refuses it, a 401 per lifetime. The sdkgen feature and the hand-written wrapper make the policy yours, at the price of a wrapper to port for each language you ship.
Write the feature at the transport seam
sdkgen ships no feature for the client credentials grant; its secrets feature trades a refresh
token for an access token over JSON. A feature is a model file
plus one source file per target, placed where the
add-a-feature guide
says. The model declares the seam, transport: 'wrap', the caller’s options under
feature.oauth, and an optspec entry so a test can inject a clock.
main: kit: feature: oauth: {
name: key()
title: "OAuth client credentials"
description: "Fetch a machine token with the client credentials grant, reuse it until it nears expiry, and buy one replacement on a 401"
version: '0.0.1'
active: true
base: 'BASE'
transport: 'wrap'
config: {
# Opt-in: enable per SDK via options.feature.oauth.active = true
options: {
active: false
tokenUrl: ''
clientId: ''
clientSecret: ''
scope: ''
# Seconds before expires_in runs out at which the token counts as spent.
margin: 10
}
}
# The feature wraps the transport in init(); it dispatches no pipeline hooks.
hook: {}
}
main: kit: feature: oauth: config: optspec: {
# injected clock in milliseconds, so a test need not wait a minute
now: '`$FUNCTION`'
}
The source replaces utility.fetcher, the function every HTTP attempt goes through, with one
that calls the previous fetcher. The
hooks reference says that
seam handles every HTTP attempt where a hook handles one operation, and a retried attempt is a
fresh request that must carry a current token.
init(ctx: Context, options: FeatureOptions): void | Promise<any> {
this._client = ctx.client
this._options = options || {}
this.active = (options as any).active
if (!this.active) {
return
}
const self = this
const utility = ctx.utility
const inner = utility.fetcher
this._fetch = ctx.options.system.fetch
utility.fetcher = async function (ctx2: any, url: string, fetchdef: any) {
return self._withToken(ctx2, url, fetchdef, inner)
}
}
async _withToken(this: any, ctx: any, url: string, fetchdef: any, inner: any): Promise<any> {
// Test mode never reaches a network, so it never needs a token.
if ('live' !== ctx.client._mode) {
return inner(ctx, url, fetchdef)
}
const headers = fetchdef.headers = fetchdef.headers || {}
const used = await this._current(ctx, fetchdef.signal)
headers.authorization = 'Bearer ' + used
let res = await inner(ctx, url, fetchdef)
// The server says the token is spent: revoked, or expired by a clock this
// client does not share. Drop it, unless a concurrent attempt has already
// replaced it, then try once more with whatever is current. A second 401
// is a real failure and goes back to the caller as it is.
if (null != res && 401 === res.status) {
if (null != this._token && used === this._token.value) {
this._token = null
}
this._track('renewals')
headers.authorization = 'Bearer ' + await this._current(ctx, fetchdef.signal)
res = await inner(ctx, url, fetchdef)
}
return res
}
_current holds one token and one in-flight request, so eight concurrent attempts on a cold
client share a single token request. _request posts grant_type=client_credentials with the
credentials in a Basic authorization header and records when to replace the token.
const token = await res.json()
const ttl = Number(token.expires_in)
const margin = null == opts.margin ? 10 : opts.margin
// expires_in is only RECOMMENDED by RFC 6749 section 5.1. Without it the
// token is kept until a 401 says otherwise.
const expiresAt = Number.isFinite(ttl) ? this._now() + Math.max(0, ttl - margin) * 1000 : Infinity
this._token = { value: token.access_token, expiresAt }
Add it by path and regenerate
feature add takes a bare name for a shipped feature, or a path to a directory shaped like the
generator’s scaffold: a .sdk folder with the model under model/feature and the source under
tm/<target>.
npx --yes @voxgig/create-sdkgen@0.30.8 fulfil --def api.json --target ts
(cd fulfil-sdk/.sdk && npx voxgig-sdkgen feature add retry)
(cd fulfil-sdk/.sdk && npx voxgig-sdkgen feature add ../../oauth-feature/oauth)
(cd fulfil-sdk/.sdk && npm run generate)
The add registered the model in feature-index.aontu and copied the source into
.sdk/tm/ts/src/feature/oauth/, with ProjectName replaced by Fulfil. Generation wired the
class in beside the shipped features and added its options to the constructor’s schema.
const FEATURE_CLASS = {
oauth: OauthFeature_1.OauthFeature,
retry: RetryFeature_1.RetryFeature,
test: TestFeature_1.TestFeature,
};
Put retry outside the token
The feature catalogue
says the transport wrapper initialized last ends up outermost and handles a call first. The default map form sorts
names alphabetically, so oauth already sits inside retry; the array form makes the choice
visible.
// ['oauth', 'retry'] initializes oauth first, so retry is outermost and every
// attempt it makes passes through oauth on its way to the transport.
export function makeClient(api, clock, order = ['oauth', 'retry'], { margin } = {}) {
const features = {
oauth: oauth({ tokenUrl: api.tokenUrl, clock, margin }),
retry: retry({ clock }),
}
return new FulfilSDK({ base: api.base, feature: order.map((name) => features[name]) })
}
The token lives sixty seconds, and the API answers the first two attempts with a 503 and Retry-After: 30. The retry feature’s injected
sleep moves the shared clock instead of waiting, so the third attempt leaves after the token has
expired.
node demo.mjs
one token for many calls
50 calls inside the lifetime token requests 1 refused 0
two minutes of calls, one per second
120 calls token requests 3 refused 0 renewed at 50 s and 100 s
eight calls at once on a cold client
8 concurrent calls token requests 1 refused 0
a token revoked on the server
1 call after revocation token requests 2 refused 1 renewals 1 result 2 orders
a 503 with Retry-After 30, twice, so the token expires during the backoff
retry outside oauth (this page) requests 3 token requests 2 refused 0 result 2 orders
oauth outside retry requests 4 token requests 2 refused 1 result 2 orders
The first block is the point: fifty calls, one token request. The second renews at 50 and 100 seconds, ten seconds before each expiry, with no refused request.
With retry outermost, the third attempt passes through the oauth wrapper, which sees the
expired token and buys a second one before the request leaves. With oauth outermost, the token
was attached before the retry loop, so the third attempt is refused and the wrapper buys a token
for a fourth. The call succeeds either way, but the wrong order costs a 401 on the wire and a round
trip on every call that straddles an expiry.
Compare the policies the other generators ship
Speakeasy generates the mechanism when the document declares oauth2 with a clientCredentials
flow and gen.yaml sets OAuth2ClientCredentialsEnabled. The generated hook, visible in
a published Speakeasy SDK,
sends client_secret_post by default and keeps one session per client id, secret, and scope
set. Its
session store
treats a token as expired sixty seconds early, and a token without expires_in as never
expiring.2 A 401 deletes the session rather than retrying: that call fails, and the next one
buys a token.
OpenAPI Generator’s Java client, on its default okhttp-gson library, ships
RetryingOAuth,
an OkHttp interceptor built on Apache Oltu.3 It fetches a token on the first request, never
reads expires_in, and on a 401 or 403 refreshes once and retries once. The template’s comment
explains the choice: “401/403 response codes most likely indicate an expired access token,
unless it happens two times in a row.”
In Go the transport is the idiom whatever generated the client: Config.Client in
golang.org/x/oauth2/clientcredentials
returns an http.Client whose transport adds and renews the token, and any SDK that accepts an
*http.Client inherits it.
cfg := &clientcredentials.Config{
ClientID: "svc-fulfil",
ClientSecret: "s3cr3t-fulfil",
TokenURL: s.srv.URL + "/oauth/token",
Scopes: []string{"orders:read"},
AuthStyle: oauth2.AuthStyleInHeader,
}
ctx := context.Background()
// cfg.Client wraps the default transport in one that adds the token and
// renews it when it expires. Any SDK that takes an *http.Client takes this.
client := cfg.Client(ctx)
go run -C go .
expires_in 60 calls 10 ok 10 token requests 1
expires_in 5 calls 10 ok 10 token requests 10
expires_in 5, ReuseTokenSourceWithExpiry(1s) calls 10 ok 10 token requests 1
The middle line is the trap. The package treats a token as expired ten seconds early, a
constant in token.go that its comment
attributes to client-server time mismatches. A five-second token is therefore spent on arrival,
and every call buys another;
ReuseTokenSourceWithExpiry
makes the buffer yours.
Check it worked
Ten tests count requests at the stand-in rather than trusting the client’s own bookkeeping, and
an eleventh checks that it answers 404 for an order named constructor. Two of them pin the
ordering argument.
test('retry outside oauth: an attempt made after the token expired carries a fresh one', () => withApi({}, async (api, clock) => {
const sdk = makeClient(api, clock, ['oauth', 'retry'])
api.failNext({ status: 503, retryAfter: 30 }, { status: 503, retryAfter: 30 })
const orders = await sdk.Order().list()
assert.equal(orders.length, 2)
assert.equal(clock.elapsed(), 60, 'two Retry-After waits moved the clock past the lifetime')
assert.equal(api.state.requests, 3, 'three attempts')
assert.equal(api.state.unauthorized, 0, 'the third attempt carried a fresh token')
assert.equal(api.state.tokenRequests, 2)
}))
test('oauth outside retry: the retried attempt carries the stale token and is refused', () => withApi({}, async (api, clock) => {
const sdk = makeClient(api, clock, ['retry', 'oauth'])
api.failNext({ status: 503, retryAfter: 30 }, { status: 503, retryAfter: 30 })
const orders = await sdk.Order().list()
assert.equal(orders.length, 2, 'the call still succeeds')
assert.equal(api.state.requests, 4, 'one more request than the other order')
assert.equal(api.state.unauthorized, 1, 'a 401 went over the wire first')
assert.equal(api.state.tokenRequests, 2)
}))
node --test oauth.test.mjs
1..11
# tests 11
# suites 0
# pass 11
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 832.237025
The test to watch is the late 401: two concurrent calls are refused, one of them answered late,
and one replacement serves both. Without the used === this._token.value check in _withToken,
the late handler throws away the token its sibling bought.
When it goes wrong
Every call buys a token, with no error anywhere. Either the margin exceeds the lifetime, so each
token is spent on arrival, or the token endpoint omits expires_in and the client keeps the
token until a 401 says it is spent.
node pitfall.mjs
margin 90 s on a 60 s token: every token arrives already spent
20 calls token requests 20 refused 0
no expires_in in the token response: a 401 is the only clock the client has
18 calls over three minutes token requests 3 refused 2 renewals 2
Twenty token requests for twenty calls is the first shape; its fix is a margin shorter than the
lifetime the endpoint grants. The second costs one refused request per lifetime, and its fix
belongs at the issuer, which
RFC 6749 asks to send expires_in
or document the lifetime.
The same symptom in Go has a different cause. The package’s default buffer expires any token
that lives ten seconds or less on arrival, as the middle line of the Go output shows, and
ReuseTokenSourceWithExpiry is the fix.
The call fails with token endpoint answered 503 and the API saw no request. A thrown error is
retryable to the retry feature outside, so the token request was tried three times before the
failure reached the caller.
When not to do this
Do not write an sdkgen feature for an issuer whose exchange is a refresh token posted as JSON.
The shipped secrets feature does that, with a single in-flight purchase, and it is already
ported to twenty targets.
Do not set the generated apikey option as well. The client sends it as a bearer token and the
wrapper overwrites it on every attempt, so the apikey never reaches the API.
Do not use the grant for a user’s data. RFC 6749 restricts client credentials to resources under the client’s own control or arranged in advance, and a token with no user in it loses per-user audit and consent.
Do not put the renewal in a pipeline hook. A PreRequest hook runs once per operation, so a
retried attempt would carry the first attempt’s token. The transport seam is the place, which is
why the feature declares transport: 'wrap'.
Related how-tos
Last verified
Verified 2026-10-21 against Node 22.22.0, Go 1.24.7, @voxgig/create-sdkgen 0.30.8,
@voxgig/sdkgen 4.35.0, and golang.org/x/oauth2 v0.35.0. Every output block is what the
command preceding it printed. The authorization server and the API are the stand-in in
server.mjs, on the loopback interface, with a clock both sides share. Speakeasy and OpenAPI
Generator were not run; their behavior is read from the generated hook, the template, and the
documentation this page links.
Footnotes
-
Section 2.3.1 of RFC 6749 says the authorization server MUST support HTTP Basic, and that credentials in the request body are NOT RECOMMENDED. The method the specification discourages is the one Speakeasy’s hook sends by default. The Go package’s comment calls Basic “an optional style described in the OAuth2 RFC 6749 section 2.3.1” and reads the section from the client’s side, where the word is MAY. ↩︎ Back to text
-
Three buffers appear on this page. The Go package subtracts ten seconds, in a comment in
token.gothat explains the subtraction as a guard against client-server time mismatches. The Speakeasy session store subtracts sixty, and its comment reads “A 60-second buffer is applied to refresh tokens before they actually expire” though the check covers every session. The feature on this page subtracts ten, because that figure arrived with a reason. ↩︎ Back to text -
The Java interceptor imports
org.apache.oltu.oauth2for its token request. Apache Oltu was retired in March 2018 and moved to the Attic, the foundation’s shelf for projects at the end of their life, that July. The template on the generator’s main branch imports it still, so a Java client generated with OAuth support depends on a library its own foundation closed in 2018. ↩︎ Back to text