Client integration
For anyone building a consumer that is not the existing Spark path. Four rules carry almost all of the risk; the rest is detail.
1. Resolve once, on one coordinating process
Resolve on whatever single process coordinates the work — a Spark driver, a job scheduler, a CLI entry
point — and distribute the resolved envelope to workers. Cache it until expiresAt, then resolve again.
Do not let every worker resolve for itself. It multiplies load on __admin and on the RDS describe
APIs, which are low-rate, and it turns a job start into a burst of identical requests. The Locator keeps
a small in-memory cache that absorbs repeats within one warm instance, but that does nothing for a cold
burst and should not be relied upon.
For Spark specifically, the format("depot") wiring resolves on the driver and serialises the result
into JDBC options, which is the shape to copy. Routing a raw depot: URL through a per-connection JDBC
provider instead pushes resolution onto every executor — avoid that shape.
2. Sign the token against the canonical endpoint, never the connection host
For credential.kind === 'RDS_IAM_TOKEN' the consumer mints its own token. It must be signed against
canonicalEndpoint, regardless of the host actually being connected to.
This separation is what makes VPC endpoints and tunnels work at all: the connection may go to an
interface-endpoint alias or to 127.0.0.1:<local port>, while the signature must still name the real
database. Getting this wrong produces an authentication failure that looks like a permissions problem.
Sign as whichever DB user you have decided to be — the Locator names none, and holds no opinion. The
partition, account and region come from the depot: URL you already parsed. If your driver wants these
as a property bag, build one; connectionProperties carries only what the store determines (TLS,
keep-alive, read-only intent), so you can pass it through and add your own entries alongside.
3. Branch on reachability, and own the tunnel (Aurora)
| Value | What to do |
|---|---|
DIRECT | Use underlyingUrl as given. The Locator knows of no indirection — you may still need to probe. |
VPC_ENDPOINT | Use underlyingUrl; it already names the interface endpoint in your VPC. |
REQUIRES_TUNNEL | Open your own tunnel, then replace the host and port in underlyingUrl with its local end. Leave connectionProperties alone. |
Pass callerVpcId when the consumer might be in a different VPC from the database — read it from
instance metadata, which costs no permission. Being in the database's own VPC is fine to declare: the
Locator recognises it and answers DIRECT. But omit it if you reach the database by peering or transit
routing, because the Locator cannot see routing and would tell you to tunnel needlessly.
Pass needsBastionHint: true only for development clients prepared to run aws ssm start-session.
4. Handle throttling, in whatever form your Lambda client surfaces it
Two distinct conditions, and they want different responses:
- Your invoke was throttled. Surfaces as HTTP 429 /
TooManyRequestsException, possibly with aRetry-After. Retry with exponential backoff and full jitter — jitter is not optional, because a synchronised burst retrying on a fixed delay re-synchronises. The AWS SDK already retries throttling internally, so your retry is the outer layer; stacking aggressive retries multiplies the burst that caused the throttle. - The Locator's own dependency was throttled. Surfaces as
outcome: 'ERROR'witherror.kind === 'DOWNSTREAM_THROTTLED',retryable: true, and sometimesretryAfterSeconds.
Every other error.kind — NOT_FOUND, INVALID_REQUEST, UNSUPPORTED_LOCATION, DOWNSTREAM_ERROR —
carries retryable: false. Branch on retryable, not on the message.
Handling the credential
For RDS_IAM_TOKEN there is nothing to handle: no secret is sent.
For SECRETS_MANAGER, the response carries only a pointer — credential.secretArn — never the secret
itself. Reading it, and turning it into driver options, is the caller's own job.
The secret's shape. Nothing upstream of the caller validates it anymore, so read it defensively. It is
JSON with these optional fields, all lowercase — privatekey, not privateKey; getting the case wrong
makes the field read as absent:
interface SnowflakeCredentialSecret {
username?: string;
password?: string;
privatekey?: string;
region?: string;
warehouse?: string;
}
Derive the driver option from it in this order: if privatekey is present, write it to
pem_private_key; else if password is present, write it to sfPassword; else the secret is unusable
for this purpose — fail loudly rather than connecting with neither.
const secret: SnowflakeCredentialSecret = JSON.parse(
(await secretsManager.getSecretValue({ SecretId: response.credential.secretArn })).SecretString!,
);
const options = { ...response.connectionProperties };
if (secret.privatekey) {
options.pem_private_key = secret.privatekey;
} else if (secret.password) {
options.sfPassword = secret.password;
} else {
throw new Error('Snowflake credential secret has neither privatekey nor password');
}
The connect-as username is the secret's own username field — there is no override to consult.
ResolvedCredential's SECRETS_MANAGER member carries no username of its own, and no deployed Depot
environment declares an executor-level override, so there is no precedence rule to apply here. Treat the
secret's username being absent as a data error, not a silent fallback.
Reading the secret is a default for the common case, not the only option. A caller with its own way
to authenticate to Snowflake — a personal access token, externalbrowser SSO, or any credential an
operator already holds for interactive or ad hoc scripts — may disregard credential and secretArn
entirely and authenticate however it already does. The rest of the envelope
(connectionProperties.sfUrl, sfWarehouse when known) is still useful on its own; secretArn merely
names a credential the caller is welcome to use, not the only one that will work.
connectionProperties never contains a secret, which means it is safe to log — and the credential itself
is never safe to log. Keeping the secret out of the response entirely (rather than in one named field) is
what makes that distinction structural now, instead of depending on a naming convention.
Connection labelling and query tags are yours
The Locator never opens a connection, so anything that labels one belongs to you:
- Aurora's
ApplicationName— set it when you assemble connection properties. The Locator does not emit it. A port that expects it in the envelope will silently lose session attribution in Postgres. - Snowflake's
query_tagand cost tags — applied per query by the caller, not part of resolution.
Keeping these out of the request also keeps them out of the cache key, avoiding a class of bug where two resolutions differing only by a label share an entry.
The request's applicationName is not this. It is a self-asserted label used only in the Locator's
log line, because a directly-invoked lambda cannot see who invoked it. It is required: send
something short and stable — it is sanitised and truncated to 40 characters — and never rely on it for
anything but forensics. Omitting it, or sending something that sanitises to nothing, is
INVALID_REQUEST.
Snowflake: authenticate as the secret's user
For Snowflake, connect as the credential secret's own username field — the Locator emits no
sfUser connection property for this kind, and names no user of its own. Using
it would authenticate as an identity the key was never issued for.
See "Handling the credential" above for reading the secret; the username you read from it there is the
same one to connect as.
sfUrl embeds the Snowflake account's region, not your AWS region. The two happen to match in current
deployments, but the account-locator URL form is region-specific and does not redirect, so the Locator
prefers the region declared for the credentials over the caller's AWS region.
Writing
Pass readOnly: false only with a schema, and only for a record schema. Anything else, including
an ordinary object table, is refused with INVALID_REQUEST. See "Asking to write" in the overview for
why the gate is this narrow. readOnly coming back in connectionProperties is the Locator's assent
rather than an echo of your request.
Authorisation is coarse today
Any principal that can invoke the Locator can resolve any dataset in the environment, and receive whatever credential that entails. Grant invoke rights narrowly and deliberately.
Same-account consumers should grant themselves lambda:InvokeFunction in their own stack, addressing the
function through gatewayEndpoints.storageLocatorArn. That keeps a service's requirements declared where
the service is defined, rather than in an environment definition it may not own. The environment-level
locator.invokePrincipals list is for cross-account callers only, where a resource policy is the
only mechanism available.
Per-dataset authorisation is designed but not built; the reasoning and the options are recorded against DPT-2934.