{"record":{"id":"0ddc4a8a9b77e96a","repo":"hashicorp/terraform","slug":"http-remote-state-already-locked-id-s","errorCode":null,"errorMessage":"HTTP remote state already locked: ID=%s","messagePattern":"HTTP remote state already locked: ID=(.+?)","errorType":"exception","errorClass":"statemgr.LockError","httpStatus":null,"severity":"error","filePath":"internal/backend/remote-state/http/client.go","lineNumber":119,"sourceCode":"\t\treturn \"\", fmt.Errorf(\"HTTP remote state endpoint invalid auth\")\n\tcase http.StatusConflict, http.StatusLocked:\n\t\tdefer resp.Body.Close()\n\t\tbody, err := io.ReadAll(resp.Body)\n\t\tif err != nil {\n\t\t\treturn \"\", &statemgr.LockError{\n\t\t\t\tErr: fmt.Errorf(\"HTTP remote state already locked, failed to read body\"),\n\t\t\t}\n\t\t}\n\t\texisting := statemgr.LockInfo{}\n\t\terr = json.Unmarshal(body, &existing)\n\t\tif err != nil {\n\t\t\treturn \"\", &statemgr.LockError{\n\t\t\t\tErr: fmt.Errorf(\"HTTP remote state already locked, failed to unmarshal body\"),\n\t\t\t}\n\t\t}\n\t\treturn \"\", &statemgr.LockError{\n\t\t\tInfo: &existing,\n\t\t\tErr:  fmt.Errorf(\"HTTP remote state already locked: ID=%s\", existing.ID),\n\t\t}\n\tdefault:\n\t\treturn \"\", fmt.Errorf(\"Unexpected HTTP response code %d\", resp.StatusCode)\n\t}\n}\n\nfunc (c *httpClient) Unlock(id string) error {\n\tif c.UnlockURL == nil {\n\t\treturn nil\n\t}\n\n\tresp, err := c.httpRequest(c.UnlockMethod, c.UnlockURL, &c.jsonLockInfo, \"unlock\")\n\tif err != nil {\n\t\treturn err\n\t}\n\tdefer resp.Body.Close()\n\n\tswitch resp.StatusCode {","sourceCodeStart":101,"sourceCodeEnd":137,"githubUrl":"https://github.com/hashicorp/terraform/blob/c9def3e214014c1188faabfc4a5bde5095139765/internal/backend/remote-state/http/client.go#L101-L137","documentation":"The legitimate 'state is already locked' outcome: the server returned 409/423, the body parsed, and the holder's lock ID is reported. This is not a bug — it means another terraform process (or a crashed one that left a stale lock) currently holds the state lock. Returned as a statemgr.LockError carrying the parsed LockInfo of the holder.","triggerScenarios":"Two engineers (or CI + engineer) running terraform apply against the same state simultaneously; a previous run crashed without unlocking; a long-running apply holding the lock. Fires at lock acquisition during plan/apply/destroy.","commonSituations":"Shared state with no lock coordination; killed CI run leaving a stale lock; interactive apply still running when a scheduled pipeline starts.","solutions":["Confirm the holder (ID, Who, Operation from the error) is not an active run you should wait for.","If the holder is a dead/crashed run, run `terraform force-unlock <ID>` using the ID printed in the message.","Add pre-run coordination (CI queueing, branch protection) to avoid concurrent applies on shared state."],"exampleFix":"# once you confirm the holder is stale:\nterraform force-unlock <ID-printed-in-the-error>","handlingStrategy":"try-catch","validationCode":null,"typeGuard":"func isAlreadyLocked(err error) (id string, ok bool) {\n  var le *statemgr.LockError\n  if !errors.As(err, &le) || le.Err == nil { return \"\", false }\n  s := le.Err.Error()\n  if !strings.Contains(s, \"already locked: ID=\") { return \"\", false }\n  return strings.TrimPrefix(s, \"...ID=\"), true // adjust prefix to actual message\n}","tryCatchPattern":"err = sm.Lock(info)\nif id, ok := isAlreadyLocked(err); ok {\n  if holderIsActive(id) { waitThenRetry() } else { promptForceUnlock(id) }\n  return\n}","preventionTips":["Serialize applies on shared state (CI queue, branch protection).","Confirm the holder is stale before force-unlock.","Alert on frequent lock conflicts as a process smell."],"tags":["locking","concurrency","http-backend","terraform"],"analyzedSha":"c9def3e214014c1188faabfc4a5bde5095139765","analyzedAt":"2026-08-07T15:39:49.278Z","schemaVersion":2},"datasetVersion":"2026-08-07T21:17:07.882Z"}