windmill-labs/windmill · error

${what}: ${supabaseErrorMessage(body) || res.statusText}

Error message

${what}: ${supabaseErrorMessage(body) || res.statusText}

What it means

unwrap is the shared response checker for Supabase Management API calls made during workspace provisioning. Any non-OK HTTP response is thrown as `<what>: <supabase-provided message or statusText>`. A 401 additionally clears the stored OAuth token so the Connect screen reappears. It surfaces the upstream Supabase error to the user with context about which call failed.

Source

Thrown at frontend/src/lib/components/workspaceSettings/supabaseProvisioning.ts:73

	{ code: 'eu-west-3', label: 'West EU (Paris)' },
	{ code: 'ap-southeast-1', label: 'Southeast Asia (Singapore)' },
	{ code: 'ap-northeast-1', label: 'Northeast Asia (Tokyo)' }
]

export const DEFAULT_SUPABASE_REGION = 'eu-central-1'

function headers(token: string): HeadersInit {
	return { 'Content-Type': 'application/json', 'X-Supabase-Token': token }
}

async function unwrap(res: Response, what: string): Promise<any> {
	if (!res.ok) {
		// Supabase access tokens are short-lived while `oauthStore` lasts as long as the tab, so
		// a stale one otherwise leaves every caller "authorized" and unable to reach the button
		// that would fix it. Forgetting it here is what puts Connect back on screen.
		if (res.status === 401) oauthStore.set(undefined)
		const body = await res.text()
		throw new Error(`${what}: ${supabaseErrorMessage(body) || res.statusText}`)
	}
	return res.json()
}

/**
 * Supabase answers with `{ message }` or `{ error }` and occasionally plain text.
 * Surfacing the raw body puts a JSON blob in front of the user, so unwrap it to
 * the sentence inside.
 */
export function supabaseErrorMessage(body: string): string {
	try {
		const parsed = JSON.parse(body)
		return parsed?.message ?? parsed?.error ?? parsed?.msg ?? body
	} catch {
		return body
	}
}

View on GitHub (pinned to e474e8803c)

Solutions

  1. If the message says unauthorized (401), click Connect again to re-authenticate — the stale token was already cleared.
  2. Generate a fresh Supabase access token with the required scopes (org read/write, project create) and reconnect.
  3. Retry after a delay if the status is 429 rate limit.
  4. Check status.supabase.com if the error is 5xx, then retry.
  5. Verify the Supabase account actually has an organization (and quota) before provisioning.
Defensive patterns

Strategy: try-catch

Validate before calling

if (!oauthStoreValue) { showConnectScreen(); return }
const testRes = await fetch(`${base}/api/oauth/supabase_orgs`, { headers: headers(token) })
if (testRes.status === 401) { oauthStore.set(undefined); showConnectScreen(); return }

Type guard

function isSupabaseErrorBody(b: unknown): b is { message?: string; error?: string } {
  return typeof b === 'object' && b !== null && ('message' in b || 'error' in b)
}

Try / catch

try {
  const orgs = await listSupabaseOrgs(token)
} catch (e) {
  if (/401|unauthorized/i.test(e.message)) promptReconnect()
  else if (/429/.test(e.message)) scheduleRetry()
  else showError(e.message)
}

Prevention

When it happens

Trigger: Any call routed through unwrap (listSupabaseOrgs, listSupabaseProjects, createSupabaseProject, pooler configs fetch) when Supabase answers 401 (expired/revoked access token), 403 (no org permission), 404, 429 (rate limit), or 5xx.

Common situations: Expired Supabase personal access token in oauthStore after sitting on the page; token created without the org/project scopes needed; Supabase rate limiting org creation; Supabase API outage.

Related errors


AI-assisted analysis of windmill-labs/windmill@e474e8803c (2026-09-03). Data as JSON: /api/errors/8cd3e4e7991d80ec. Report an issue: GitHub.