fix(digitalocean): detect droplet limit before creation, clear error on 422 (#2891)

checkAccountStatus() now queries the account's droplet_limit and
current droplet count. When at capacity it warns interactively and
throws immediately in headless/E2E mode with a clear message instead
of attempting creation and getting a cryptic 422.

Also adds specific detection of droplet limit 422 errors in
createServer() with actionable guidance (limit increase URL).

Bump CLI to 0.25.14.

Fixes #2865

Agent: code-health

Co-authored-by: B <6723574+louisgv@users.noreply.github.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
A 2026-03-22 18:49:17 -07:00 committed by GitHub
parent 83cd6bc6df
commit 0224b56a4d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 39 additions and 6 deletions

View file

@ -1,6 +1,6 @@
{
"name": "@openrouter/spawn",
"version": "0.25.13",
"version": "0.25.14",
"type": "module",
"bin": {
"spawn": "cli.js"

View file

@ -390,9 +390,10 @@ export async function promptSwitchAccount(): Promise<boolean> {
}
/**
* Check DigitalOcean account status for billing issues.
* Check DigitalOcean account status for billing issues and droplet limits.
* Uses the /v2/account endpoint which is already called during token validation.
* Throws if the account is locked (billing issue). Warns on other statuses.
* Throws if the account is locked (billing issue) or at the droplet limit (in headless mode).
* Warns on other statuses.
*/
export async function checkAccountStatus(): Promise<void> {
if (!_state.token) {
@ -407,6 +408,7 @@ export async function checkAccountStatus(): Promise<void> {
}
const status = isString(rec.status) ? rec.status : "";
const emailVerified = rec.email_verified;
const dropletLimit = isNumber(rec.droplet_limit) ? rec.droplet_limit : 0;
if (status === "locked") {
logWarn("Your DigitalOcean account is locked (usually a billing issue).");
@ -440,10 +442,31 @@ export async function checkAccountStatus(): Promise<void> {
if (emailVerified === false) {
logWarn("Your DigitalOcean email is not verified. Verify it to avoid account restrictions.");
}
// Check droplet limit — fail fast before attempting creation
if (dropletLimit > 0) {
const existingDroplets = await asyncTryCatch(() => doGetAll("/droplets", "droplets"));
if (existingDroplets.ok) {
const currentCount = existingDroplets.data.length;
if (currentCount >= dropletLimit) {
const msg = `DigitalOcean droplet limit reached: ${currentCount}/${dropletLimit} droplets in use. Delete existing droplets or request a limit increase at https://cloud.digitalocean.com/account/team/droplet_limit_increase`;
logWarn(msg);
if (process.env.SPAWN_NON_INTERACTIVE === "1") {
throw new Error(msg);
}
} else if (dropletLimit - currentCount <= 2) {
logWarn(`DigitalOcean droplet quota almost full: ${currentCount}/${dropletLimit} droplets in use.`);
}
}
}
});
if (!r.ok) {
// Only re-throw if it's our explicit lock error
if (r.error instanceof Error && r.error.message === "DigitalOcean account is locked") {
// Re-throw explicit errors (account locked, droplet limit in headless mode)
if (
r.error instanceof Error &&
(r.error.message === "DigitalOcean account is locked" ||
r.error.message.startsWith("DigitalOcean droplet limit reached"))
) {
throw r.error;
}
// Otherwise non-fatal — let createServer be the final check
@ -1086,10 +1109,20 @@ export async function createServer(
}
logError(`Retry failed: ${String(retryData?.message || "Unknown error")}`);
}
} else if (/droplet.limit|limit.exceeded|error 422.*unprocessable/i.test(errMsg)) {
logError(
"Droplet limit exceeded. Delete existing droplets or request a limit increase at https://cloud.digitalocean.com/account/team/droplet_limit_increase",
);
// Offer account switch — user might have another account with capacity
const switched = await promptSwitchAccount();
if (switched) {
logStep("Retrying droplet creation with new account...");
return createServer(name, tier, dropletSize, region, imageOverride);
}
} else {
showNonBillingError(digitaloceanBilling, [
"Region/size unavailable (try different DO_REGION or DO_DROPLET_SIZE)",
"Droplet limit reached (check account limits)",
"Droplet limit reached (check account limits at https://cloud.digitalocean.com/account/team/droplet_limit_increase)",
]);
// Offer account switch for non-billing errors too (e.g. quota on wrong account)
const switched = await promptSwitchAccount();