Accepted
2026-02-04 (retroactive documentation)
The provider has a three-layer architecture (HTTP client → domain client → resource) and each layer handles errors differently. Without documented conventions, error message formats diverge across resources — the codebase currently mixes "Unable to", "Error", "Failed to", "Could not", and "Cannot" prefixes. Contributors need a single pattern to follow.
Additionally, retry behavior for transient failures, async task polling, and resource state waiting needs clear guidance.
User-facing diagnostic summaries (the first argument to resp.Diagnostics.AddError) must use the format:
"Unable to [Action] [Resource]"
Where [Action] is a CRUD verb and [Resource] identifies the Proxmox object.
Note: Legacy resources still use other prefixes ("Error", "Failed to", "Could not"). All new code must use the
"Unable to"format. Legacy resources will be migrated over time.
Standard summaries for the resource lifecycle:
| Operation | Summary Format | Example |
|---|---|---|
| Create | "Unable to Create [Resource]" |
"Unable to Create SDN VNet" |
| Read-back after create | "Unable to Read [Resource] After Creation" |
"Unable to Read SDN VNet After Creation" |
| Read | "Unable to Read [Resource]" |
"Unable to Read SDN VNet" |
| Update | "Unable to Update [Resource]" |
"Unable to Update SDN VNet" |
| Read-back after update | "Unable to Read [Resource] After Update" |
"Unable to Read SDN VNet After Update" |
| Delete | "Unable to Delete [Resource]" |
"Unable to Delete SDN VNet" |
| Import | "Unable to Import [Resource]" |
"Unable to Import SDN VNet" |
| Not found (import/datasource) | "[Resource] Not Found" |
"SDN VNet Not Found" |
When the resource name or ID is available, include it in the summary using fmt.Sprintf (e.g., fmt.Sprintf("Unable to Read SDN VNet %q", id)). Domain clients do not consistently include the resource identity in their error messages, so the summary is the only reliable place for it.
The detail string (second argument) should be err.Error(), which carries the full error chain from the API layer.
tasks.TaskResult.AddDiags(diags, summary) and AddDiagsAsWarnings(diags, summary) (proxmox/nodes/tasks/task_result.go) pass summary straight through to diags.AddError / AddWarning. The "Unable to [Action] [Resource]" format therefore applies to those call sites exactly as it does to direct AddError calls:
vmAPI.CreateVM(ctx, body).AddDiags(diags, fmt.Sprintf("Unable to Create VM %d", id))
vmStop(ctx, vmAPI).AddDiagsAsWarnings(diags, fmt.Sprintf("Unable to Stop VM %d", id))AddDiagsAsWarnings exists for deliberately non-fatal task failures (e.g. a best-effort stop before delete) — the severity is downgraded by the caller's choice, but the operation still failed, so the "Unable to" prefix still applies.
A format conformance sweep that greps only AddError( misses these call sites — audit all four shapes:
grep -nE 'AddError\(|AddWarning\(|AddDiags\(|AddDiagsAsWarnings\(' fwprovider/...Use resp.Diagnostics.AddError() for all failures that prevent the operation from completing. Do not use resp.Diagnostics.AddWarning() for API errors — if the operation failed, it's an error, not a warning.
Warnings are acceptable only for non-fatal informational messages, such as deprecation notices emitted by the schema framework itself. Resource CRUD methods should not emit warnings.
After a successful Create or Update API call, always read back the resource from the API before setting state. Never save the plan directly to state.
// Create: after successful API call
data, err := r.client.GetResource(ctx, id)
if err != nil {
resp.Diagnostics.AddError("Unable to Read [Resource] After Creation", err.Error())
return
}
readModel := &model{}
readModel.fromAPI(id, data)
resp.Diagnostics.Append(resp.State.Set(ctx, readModel)...)The same pattern applies to Update — read back and use "Unable to Read [Resource] After Update" as the error summary.
Why this matters:
- The Proxmox API may normalize values (e.g., trimming whitespace, canonicalizing paths)
- Computed fields are only available from the API response, not the plan
- Server-side defaults for Optional+Computed fields must be captured
- Saving plan data directly creates state drift that is only detected on the next refresh
Errors flow through three layers. Each layer adds context while preserving the original error for inspection.
The base API client (client.go) handles HTTP-level concerns:
- Returns
HTTPError{Code, Message}for non-2xx responses - Parses the Proxmox error response body to extract field-level errors and messages
- Joins
ErrResourceDoesNotExistsentinel with theHTTPErrorfor 404 responses and 500 responses containing "does not exist"
// Sentinel errors
const ErrNoDataObjectInResponse Error = "the server did not include a data object in the response"
const ErrResourceDoesNotExist Error = "the requested resource does not exist"
// For not-found cases, both errors are joined so callers can check either:
// This happens for HTTP 404 AND for HTTP 500 responses containing "does not exist":
errors.Join(ErrResourceDoesNotExist, &HTTPError{Code: 404, Message: "..."})
errors.Join(ErrResourceDoesNotExist, &HTTPError{Code: 500, Message: "...does not exist..."})Not-found string matching is deliberately case-sensitive, matching the exact case of the upstream emitter: PVE Perl plugins emit lowercase (does not exist, no such resource); libc-derived strings passed through tools like qemu-img, rbd, or lvm are capitalized (No such file or directory, i.e. strerror(ENOENT)). Do not broaden the matcher to case-insensitive "for robustness" — once an error is classified as ErrResourceDoesNotExist, retry loops short-circuit and call sites downgrade to "resource gone" semantics, so a too-broad match silently misclassifies recoverable transient errors. When adding a pattern, identify its emitter (PVE Perl source, or a libc passthrough) and match that exact case. When the not-found signal is domain-specific (Ceph pools, qemu-img output, …), translate it at the domain client's call site instead of widening the central matcher — see isUnrecognizedPoolErr in proxmox/nodes/ceph/pool/ for the reference.
Domain client methods wrap errors with operational context using fmt.Errorf and the %w verb to preserve the error chain:
func (c *Client) GetZone(ctx context.Context, id string) (*ZoneData, error) {
resBody := &struct{ Data *ZoneData `json:"data"` }{}
err := c.DoRequest(ctx, http.MethodGet, c.ExpandPath(id), nil, resBody)
if err != nil {
return nil, fmt.Errorf("error reading SDN zone %s: %w", id, err)
}
if resBody.Data == nil {
return nil, api.ErrNoDataObjectInResponse
}
return resBody.Data, nil
}Resources check sentinel errors to determine behavior, then convert remaining errors to Terraform diagnostics:
Read — remove from state when resource is gone:
if errors.Is(err, api.ErrResourceDoesNotExist) {
resp.State.RemoveResource(ctx)
return
}
resp.Diagnostics.AddError("Unable to Read SDN VNet", err.Error())Delete — ignore already-gone resources:
err := r.client.SDNVnets(state.ID.ValueString()).DeleteVnet(ctx)
if err != nil && !errors.Is(err, api.ErrResourceDoesNotExist) {
resp.Diagnostics.AddError("Unable to Delete SDN VNet", err.Error())
}Import/Datasource — report not-found as an error:
if errors.Is(err, api.ErrResourceDoesNotExist) {
resp.Diagnostics.AddError("SDN VNet Not Found",
fmt.Sprintf("SDN VNet with ID '%s' was not found", req.ID))
return
}Every call to resp.State.Set(ctx, ...) must be wrapped in resp.Diagnostics.Append():
// Correct: errors from State.Set are propagated
resp.Diagnostics.Append(resp.State.Set(ctx, readModel)...)Never use a bare resp.State.Set() call:
// WRONG: errors from State.Set are silently discarded
resp.State.Set(ctx, &plan)If State.Set fails (e.g., due to a type mismatch between the model struct and the schema), the error will be silently lost. Terraform will report success while the state is incomplete or corrupted.
Datasources differ from resources in their error handling for not-found scenarios:
| Scenario | Resource Behavior | Datasource Behavior |
|---|---|---|
| Target not found | resp.State.RemoveResource(ctx) (triggers recreation) |
resp.Diagnostics.AddError("[Resource] Not Found", ...) (configuration error) |
| Read source | req.State.Get(ctx, &state) |
req.Config.Get(ctx, &config) |
| Configure type | config.Resource |
config.DataSource |
A datasource must never call resp.State.RemoveResource(ctx). If the queried resource does not exist, this is a configuration error — the user specified an invalid ID. Return an error diagnostic instead:
if errors.Is(err, api.ErrResourceDoesNotExist) {
resp.Diagnostics.AddError("[Resource] Not Found",
fmt.Sprintf("[Resource] with ID '%s' was not found", id))
return
}After a successful Read, all Computed attributes must have a known value — null means "unknown" which is only valid during planning. Convert nil API pointers to sensible defaults: "" for strings, false for bools, empty collections for sets/maps.
For list datasources that return collections (e.g., proxmox_backup_jobs), an empty result set is valid and should not produce an error.
Use standard Go error inspection:
| Need | Method |
|---|---|
| Check for sentinel | errors.Is(err, api.ErrResourceDoesNotExist) |
| Extract HTTP status | errors.As(err, &httpError) then inspect httpError.Code |
| Add context | fmt.Errorf("context: %w", err) |
Retry logic is centralized in the proxmox/retry/ package, which provides three operation constructors:
| Constructor | Use Case | Attempts | Backoff | Method |
|---|---|---|---|---|
NewTaskOperation |
Async UPID-based tasks (create, clone, delete, start) | 3 | Exponential | DoTask |
NewAPICallOperation |
Synchronous blocking API calls (e.g. PUT /config) | 3 | Exponential | Do |
NewPollOperation |
Wait-for-condition loops (status, config unlock) | Unlimited | Fixed (1s) | DoPoll |
Key behaviors of DoTask:
dispatchFnis retried;waitFnerrors are wrapped inUnrecoverable(no re-dispatch after wait failure)niltaskID withnilerror means "already done" (e.g., "already running") — skips the waitWithAlreadyDoneCheckis only applied on retry attempts, not the first attempt
Common retry predicates:
| Predicate | Matches |
|---|---|
IsTransientAPIError |
HTTP 5xx, "got no worker upid", "got timeout" |
ErrorContains(substr) |
Error message contains substring |
Do not retry on:
- 4xx client errors (except specific known-transient messages)
ErrResourceDoesNotExist(unless polling for creation)- Authentication failures (401, 403)
Delete predicate trap:
ErrResourceDoesNotExistcan arrive via HTTP 500 (not just 404) becauseproxmox/api/client.gouseserrors.Join(ErrResourceDoesNotExist, httpError)for 500 responses containing "does not exist". This meansIsTransientAPIErroralone will match these errors (it checks for 5xx). Delete operations must use a combined predicate:retry.WithRetryIf(func(err error) bool { return retry.IsTransientAPIError(err) && !errors.Is(err, api.ErrResourceDoesNotExist) })
- Consistent error messages across all resources
- Error chain preserved for debugging (
err.Error()includes all layers) - Sentinel errors enable correct Read/Delete behavior without string matching
- Retry policies prevent flaky behavior from transient failures
- Existing resources using other formats ("Error", "Failed to", etc.) should be migrated over time
- The
errors.Joinpattern for not-found detection relies on string matching in the HTTP layer for 500 responses
- Using string matching (
strings.Contains(err.Error(), "not found")) instead oferrors.Is(err, api.ErrResourceDoesNotExist). - Returning errors from Delete when the resource is already gone — check
errors.Is(err, api.ErrResourceDoesNotExist)first. - Using
"Error","Failed to", or"Could not"prefixes in new code — use"Unable to [Action] [Resource]". - Wrapping errors with
fmt.Errorf("...: %v", err)instead of%w— breaks error chain inspection. - Forgetting
resp.State.RemoveResource(ctx)in Read when the resource no longer exists. - Using
IsTransientAPIErroralone as a delete retry predicate — it will retry onErrResourceDoesNotExistwhen it arrives via HTTP 500. Always combine with!errors.Is(err, api.ErrResourceDoesNotExist). See Retry Policies. - Saving
plandirectly to state after Create (resp.State.Set(ctx, &plan)) — always read back from the API. See Read-Back After Create and Update. - Using bare
resp.State.Set()without wrapping inresp.Diagnostics.Append()— silently discards state serialization errors. See State Set Error Propagation. - Calling
resp.State.RemoveResource(ctx)in a datasource Read — datasources must error on not-found, not remove state. See Datasource Error Handling. - Auditing summary format by grepping
AddError(only —TaskResult.AddDiags/AddDiagsAsWarningscarry summaries too. See Task Result Diagnostics. - Making the not-found string matching case-insensitive, or adding a pattern without checking which case the emitter produces. See Layer 1.
- Reference Examples — error handling in CRUD methods
- ADR-002: API Client Structure — domain client layer errors
- ADR-004: Schema Design Conventions — model conversion patterns
proxmox/api/errors.go— sentinel error definitionsproxmox/api/client.go— HTTP error wrapping