feat(vultr): new @distilled.cloud/vultr SDK#291
Conversation
Generates 231 typed operations from the Vultr v2 OpenAPI spec
(community mirror at zeyuri/openapi-vultr — 155 paths, full Compute +
Managed Databases coverage). Follows the fly-io package layout:
- src/credentials.ts — VULTR_API_KEY env var, Context.Service + Redacted
- src/client.ts — Bearer auth, { error: string } body shape per Vultr docs
- src/errors.ts — standard 4xx/5xx classes from sdk-core + UnknownVultrError
- src/retry.ts — Effect 4 Context.Service shape (matches neon)
- scripts/generate.ts — preprocesses {hyphen-id} path params to camelCase
before invoking the shared OpenAPI generator (the generator emits unquoted
property keys, so {baremetal-id} would otherwise break)
bun run generate and bun run typecheck both pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The community spec from boywithkeyboard-archive/openapi-vultr was a 2-year-old snapshot. Replaced with the current spec downloaded from https://www.vultr.com/api/. Adds full coverage for: IAM (groups/policies/roles/trusts/sessions), OIDC providers + JWKS, SCIM users/groups, CDNs (pull/push zones), Inference, Marketplace, Tickets, Storage Gateways, VFS, Logs, Bare-metal reverse-DNS, BGP setup, Block snapshots, API keys, NAT gateways, Instance templates, Database connectors/quotas/topics/usage + kafka-connect/kafka-rest/schema-registry advanced options, promote-read-replica. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
bun run packages/vultr/test/smoke.ts (with VULTR_API_KEY in env) exercises credentials, HTTP wiring, response decoding, and typed error matching against the production Vultr API. Read-only — costs nothing to run. Covers listPlans, listInstances, listDatabasePlans, listDatabases, and a 404 → NotFound error-class check via getInstance.
Generated by `bun scripts/generate-nuke.ts vultr`. Enumerates 17 resource categories (Instance, BareMetal, Database, KubernetesCluster, LoadBalancer, ObjectStorage, VFS, StorageGateway, Inference, PullZone, PushZone, Registry, NatGateway, BlockSnapshot, Block, Snapshot, Iso, InstanceTemplate, ReservedIp, Vpc, Vpc2, FirewallGroup, DnsDomain, SshKey, StartupScript). Excludes IAM/SCIM/OIDC/Tickets/Subaccounts by design (account-admin scope). Supports --dry-run and packages/vultr/nuke-config.json exclusion list. Verified --dry-run reports 0 resources on a clean account, all 17 list endpoints reached, typecheck clean.
Discovered via `bun scripts/error-discovery.ts vultr` probing the live
API across Compute / Database / BareMetal / Object Storage / CDN /
Load Balancer.
Two real bugs caught:
1. `client.ts:matchError` strict-decoded the error body as
`{ error, status }` per the spec's prose. In practice many
/databases/{databaseId}/* endpoints return `{ message }` with no
status field and the strict decode fell through to
UnknownVultrError, swallowing the typed class. Now accepts both
shapes and uses the HTTP status as the source of truth for class
mapping.
2. The spec only declared 422 on database write endpoints, but every
/databases/{databaseId}/* op returns 422 for an invalid UUID, and
POST /cdns/pull-zones returns 422 for invalid origin_domain.
`patches/001-add-422-responses.patch.json` adds 39 JSON Patch
entries. After `bun run generate`, 38 database ops + 1 CDN op now
include `UnprocessableEntity` in their typed `errors` array.
Verified: typecheck, lint, format all clean. The `smoke.ts` live test
still passes — the bogus-UUID 404 probe still surfaces as the
NotFound class. No orphan resources left on the Vultr account.
No new error classes needed — every observed response across the
in-scope surface maps cleanly to the standard HTTP_STATUS_MAP from
sdk-core. Vultr's error bodies are plain `{ error|message: string }`
without any vendor-specific code structure.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
| inputSchema: GetDatabaseConnectorConfigurationSchemaInput, | ||
| outputSchema: GetDatabaseConnectorConfigurationSchemaOutput, | ||
| errors: [BadRequest, Forbidden, NotFound] as const, | ||
| errors: [BadRequest, Forbidden, NotFound, UnprocessableEntity] as const, |
There was a problem hiding this comment.
The main kinds of errors we care about:
- NotFound
- Unauthroized
- Throttling
- Conflict
- Transient errors (internal error)
We also use categories to tag errors as retryable so that a blanket retry policy can be applied across multiple calls (see core and cloudflare for examples of how this works with an openapi schema)
There was a problem hiding this comment.
Looked at this carefully — we're already aligned on the wiring. NotFound / Unauthorized / Conflict / BadRequest / UnprocessableEntity are typed per-op via patches/001-add-422-responses.patch.json and patches/002-add-missing-status-codes.patch.json.
TooManyRequests (Throttling) and the 5xx classes (Transient) intentionally aren't in per-op errors arrays — defaultErrorStatuses in packages/core/scripts/generate-openapi.ts:1153 strips them as cross-cutting:
const defaultErrorStatuses =
config.defaultErrorStatuses ?? new Set(["401", "429", "500", "503"]);
// ...
for (const status of Object.keys(operation.responses)) {
if (status.startsWith("2") || defaultErrorStatuses.has(status)) continue;
// ↑ 401/429/5xx never get added to per-op `errors`They surface at runtime through packages/vultr/src/client.ts matchError → HTTP_STATUS_MAP[status], with categories pre-attached on the sdk-core base classes:
TooManyRequests→withThrottlingError + withRetryable({ throttling: true })InternalServerError/BadGateway/ServiceUnavailable/GatewayTimeout→withServerError + withRetryable()
So Vultr.Retry.throttling and Vultr.Retry.transient fire uniformly across the whole SDK without per-op type changes.
Just pushed 298422d with packages/vultr/test/retry.test.ts — 7 tests covering the category attachments and the retry policies as happy-path identities. Tried to also assert against a real live 429 by bursting 200 parallel listRegions, but Vultr's actual limiter is way looser than the documented 30 req/s and never fired — fell back to synthetic class checks.
Pass-2 error-discovery covered Kubernetes, VFS, Storage Gateway, NAT
Gateway, Inference, Marketplace, Pull/Push CDN, Snapshot, BlockSnapshot,
Block Storage, ISO, Reserved IP, InstanceTemplate, Vpc/Vpc2, Firewall,
SshKey, StartupScript, BGP, Container Registry, Account/Billing reads,
BareMetal lifecycle, DNS.
Live probes found 4 operations missing 404 declarations:
GET /blocks/{blockId} had only 400/403
GET /blocks/snapshots/{snapshotId} had only 400/403
POST /snapshots had only 400 (instance_id 404)
POST /object-storage/{objectStorageId}/bucket had only 400/403 (parent 404)
patches/002-add-missing-status-codes.patch.json adds them. After regen
those four ops include NotFound in their typed errors array.
Vultr-side quirks documented (not patchable at spec level — see metadata):
- Kubernetes ops return 500 instead of 404 for bogus cluster id
- Storage Gateway mutations return 500 { error, error_type:'client' }
with no `status` field
- GET /reserved-ips/{id} for missing id returns HTTP 200 with embedded
error envelope (surfaces as VultrParseError, not a status-class)
- createPushzone silently accepts invalid `regions` as 201
- /vpc2 endpoints now marked deprecated:true; stale pre-deprecation
generated files still in src/operations/ — POST /vpc2 returns 403
"VPC2 networks are deprecated"
All probe resources self-cleaned during the run (block, firewall group,
push zone, inference subscription — all 204 on DELETE). nuke --dry-run
post-run reports 0 resources. typecheck + smoke still pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds packages/vultr/test/retry.test.ts. 7 tests, all green:
- 5 synthetic: confirms TooManyRequests / InternalServerError /
BadGateway / ServiceUnavailable / GatewayTimeout each carry the
sdk-core category traits Vultr.Retry.throttling and
Vultr.Retry.transient match against (ThrottlingError + retryable,
ServerError + retryable respectively).
- 2 live: a single listRegions wrapped in Retry.throttling /
Retry.transient succeeds end-to-end. Verifies the policies are
a stable identity on a happy-path call.
We tried to provoke a real 429 via a 200-parallel listRegions burst
to also test the retry-on-throttled path live, but Vultr's actual
rate limiter is looser than the documented 30 req/s and never fired.
Skipped that test path; the synthetic category attachment + the
matchError → HTTP_STATUS_MAP wiring give us equivalent coverage
without flakiness against an unpredictable upstream.
This addresses the maintainer's note re: NotFound / Unauthorized /
Throttling / Conflict / Transient being the priority error categories
and the category-tagged retry policy being the load-bearing piece.
NotFound/Unauthorized/Conflict are typed per-op via patches/001 + 002;
Throttling/Transient flow through matchError at the client level (by
generator design — defaultErrorStatuses in scripts/generate-openapi.ts
excludes 401/429/5xx from per-op `errors` arrays because they're
cross-cutting), with the categories on those classes powering
Retry.throttling / Retry.transient as verified here.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
| export const listDatabases = /*@__PURE__*/ /*#__PURE__*/ API.make(() => ({ | ||
| inputSchema: ListDatabasesInput, | ||
| outputSchema: ListDatabasesOutput, | ||
| errors: [BadRequest, Forbidden, NotFound] as const, | ||
| })); |
There was a problem hiding this comment.
This should be a paginated API. Take a look at core, cloudflare, and shared open api generator to see how we do this kind of thing. Need to identify cursor/next token/etc.
distilled paginated APIs replace manual pagination with an Effect Stream
listDatabases.pages({..}).pipe(Stream.map(..))
listDatabases.items({..}).pipe(Stream.map(..))"items" vs "pages" are API specific.
|
From our experience ai isn't great at generating distilled SDKs when operating outside of the harness we have for it (create-sdk-full script). One example is that test coverage is not per operation and the pr comment implies that error discovery wasn't run meaning there could be a lot of missing errors. Can we please redo this with the create-sdk-full script we have in the repo; manually repairing it is a really deep rabbit hole. |
will do |
Adds an Effect-native Vultr v2 SDK. 510 typed operations generated from the live Vultr OpenAPI 3.0 spec (mirror: zeyuri/openapi-vultr, 330 paths / 161 schemas, fetched from www.vultr.com/api/ on 2026-05-12). Full Compute + Managed Databases + IAM + OIDC + SCIM + CDN + Inference + Marketplace + VFS + Storage Gateways + Tickets surface.
Verified live against the production Vultr API via
packages/vultr/test/smoke.ts— credentials, query-string param encoding, response decoding, and typed error matching (404 →Errors.NotFound) all green.Error modeling in the upstream spec
Asked by the maintainers — answer is "essentially zero":
The error envelope shape (
{ error: string }) lives only in the spec's top-level prose intro, never as aSchema. The agent therefore hand-wrote the entire error matcher insrc/client.tsagainst the prose shape and falls back to the standard status-code error classes from@distilled.cloud/sdk-core/errors(BadRequest,Unauthorized,Forbidden,NotFound,Conflict,TooManyRequests, etc.):Practical implication: we get correct status-class errors on every operation, but no granular per-operation typed errors (
InstanceLocked,PlanNotAvailableInRegion,QuotaExceeded, …) the way the Neon / PlanetScale SDKs do via JSON Patch. The patches/ directory is wired up — adding typed errors as we encounter them in tests is the planned follow-up.Two generator bugs surfaced while testing
/regionsand/vfs/regionsboth generated aslistRegions; same with two pushzone endpoints colliding ongetPushzone.src/operations/index.tseven has duplicateexport * from "./listRegions.ts"lines. The OpenAPI generator's name derivation needs a path-based tiebreaker. 2 collisions across 510 operations — the 508 non-colliding ones all generate cleanly.database.statusnot enum'd in the spec: documents values asConfiguring | Rebuilding | Rebalancing | Runningin the description, but types the field as plainstring. Trivial patch but the kind of thing that causes a real downstream bug (a consumer of mine was comparing against lowercase"running"until I noticed the spec returns capitalized).Notes / open items
zeyuri/openapi-vultr). Vultr doesn't publish the OpenAPI at a stable URL — their docs page generates ablob:URL on demand. The mirror is a 1:1 copy from the docs site as of 2026-05-12. If you'd rather we live underalchemy-run/distilled-spec-vultr, I can move it; a dailyfetch-specs.tsagainst the docs page would keep it fresh.scripts/generate.tshas a pre-processing pass that camelCases hyphenated path parameter names ({baremetal-id}→baremetalId) — the shared generator emits unquoted property keys, which is invalid for hyphenated names. Could probably lift intosdk-core/openapi/generateif other vendors hit it.Vultr.Compute.InstanceandVultr.Database.Postgresresources. Currently uses a hand-rolled HTTP wrapper since this SDK didn't exist yet; once this lands I'd port it to consume@distilled.cloud/vultr.Reject as-is, scope it down, or ask for the spec source / generator collision fix first — happy to follow whichever direction makes review easiest.