Skip to content

13. Handling API Errors

In this lesson, we will address how to manage API errors returned by OpenAI.

Signaling API Errors

An incorrect model specified in a request to OpenAI will trigger an API error. Let's set the chatgpt-model variable to a nonexistent model:

(setq chatgpt-model "foo")

Now, when we send the request "Hello!" using the chatgpt-send command, the following error is displayed in the echo area:

error in process sentinel: Wrong type argument: char-or-string-p, nil

This error arises from the sentinel function within the chatgpt-send-request function. To gather more details, we activate the debugger with the toggle-debug-on-error command.

Upon resending the "Hello!" request, we enter the debugger with the following stack trace:

Debugger entered--Lisp error: (wrong-type-argument char-or-string-p nil)
  insert("# Request\n\n" ... "## Response\n\n" nil "\n\n")
  ...
  chatgpt-callback(#("Hello!" 0 6 (fontified t)) nil "/home/tony/chatgpt-emacs/requests/J2jFKb/")
  ...

Here, we notice that a nil value is being inserted into a buffer in the chatgpt-callback function. Specifically, the response parameter is nil. In the chatgpt-callback function body, this is evident:

(defun chatgpt-callback (prompt response req-dir)
  "..."
  (let ((buff (get-buffer-create "*chatgpt[requests]*")))
    (with-current-buffer buff
      ...
      (insert "# Request\n\n"
              ...
              "## Response\n\n" response "\n\n"))
    ...))

Examining the sentinel function in chatgpt-send-request, we see the response is derived from the expression (map-nested-elt resp [:choices 0 :message :content]), where resp represents the JSON response converted to an Emacs Lisp object. The issue stems from the JSON response received from OpenAI.

(lambda (process event)
  (if (not (string= event "finished\n"))
      (message "Error")
    (let* ((resp (with-current-buffer (process-buffer process)
                   (goto-char (point-min))
                   (chatgpt-json-read)))
           (response (map-nested-elt resp [:choices 0 :message :content]))
           (resp-path (concat req-dir "response.json")))
      (write-region (chatgpt-json-encode resp) nil resp-path)
      (chatgpt-callback prompt response req-dir))
    (kill-buffer (process-buffer process))))

Upon inspecting the response file located at /home/tony/chatgpt-emacs/requests/J2jFKb/, we discover that it contains an error field rather than a choices array, indicating that there was an API error. This explains why response was nil: the response from OpenAI did not include the expected path [:choices 0 :message :content].

{
    "error": {
        "message": "The model `foo` does not exist or you do
not have acces to it.",
        "type": "invalid_request_error",
        "param": null,
        "code": "model_not_found"
    }
}

To handle this scenario, we modify the sentinel function to check for the presence of an :error key in resp. If found, we bind api-error to its value and signal an api-error with :error set to api-error. Otherwise, we will proceed as usual by writing the response to disk and invoking chatgpt-callback.

(lambda (process event)
  (if (not (string= event "finished\n"))
      (message "Error")
    (let* ((resp (with-current-buffer (process-buffer process)
                   (goto-char (point-min))
                   (chatgpt-json-read))))
      (if-let ((api-error (plist-get resp :error)))
          (error "%S" `(:type "api-error" :error ,api-error))
        (let ((response (map-nested-elt resp [:choices 0 :message :content]))
              (resp-path (concat req-dir "response.json")))
          (write-region (chatgpt-json-encode resp) nil resp-path)
          (chatgpt-callback prompt response req-dir))))
    (kill-buffer (process-buffer process))))

We can test this by issuing chatgpt-send while the model remains incorrect. The error message displayed in the echo area will now provide detailed information rather than the generic error:

if: (:type "api-error" :error (:message "The model `foo`
does not exist or you do not have access to it." :type
"invalid_request_error" :param nil :code "model_not_found"))

Next, we can set the chatgpt-model to a valid model:

(setq chatgpt-model "gpt-4o")

By sending a "Hello!" request again, we should see the *chatgpt[requests]* buffer populated with this response:

# Request

<!-- [](/home/tony/chatgpt-emacs/requests/retcdg/) -->

## Prompt

Hello!

## Response

Hello! How can I assist you today?

Saving API Errors

While signaling API errors is valuable, saving these errors for future reference is even more beneficial. Therefore, we adjust the sentinel function to write the error to disk. We store the error in a file named error.json within the req-dir directory before signaling the error:

(lambda (process event)
  (if (not (string= event "finished\n"))
      (message "Error")
    (let* (...)
      (if-let ((api-error (plist-get resp :error)))
          (let ((err `(:type "api-error" :error ,api-error))
                (err-path (concat req-dir "error.json")))
            (write-region (chatgpt-json-encode err) nil err-path)
            (error "%S" err))
        ...))
    (kill-buffer (process-buffer process))))

Signaling and Saving Process Errors

Next, we implement a mechanism to handle and save errors that arise from process status changes. Currently, when an event other than "finished" occurs, we simply print "Error". We now enhance this to save the error within the corresponding request directory and signal the error. This is similar to handling API errors, except the error will be of type process-error, with the :error key containing the triggering event information.

(lambda (process event)
  (if (not (string= event "finished\n"))
      (let ((err `(:type "process-error" :error (:event ,event)))
            (err-path (concat req-dir "error.json")))
        (write-region (chatgpt-json-encode err) nil err-path)
        (error "%S" err))
    ...))

To test our changes to chatgpt-send-request, we temporarily modify the chatgpt-send function to immediately terminate the process using the kill-process function. This allows us to evaluate the new execution path:

(defun chatgpt-send ()
  "Send the current prompt to OpenAI."
  (interactive)
  (kill-process (chatgpt-send-request (buffer-string)))
  (erase-buffer)
  (when (> (length (window-list)) 1)
    (delete-window))
  (message "Request sent to OpenAI."))

After invoking the chatgpt-send command with the prompt "Hello!", the following error is signaled in the echo area:

(:type "process-error" :error (:event "killed\n"))

Additionally, we can confirm the request directory from the *Messages* buffer:

chatgpt: /home/tony/chatgpt-emacs/requests/Ot8tMl/
Wrote /home/tony/chatgpt-emacs/requests/Ot8tMl/request.json
Request sent to OpenAI.
Wrote /home/tony/chatgpt-emacs/requests/Ot8tMl/error.json
let: (:type "process-error" :error (:event "killed\n"))

We can also verify that the error file at /home/tony/chatgpt-emacs/requests/Ot8tMl/error.json contains the following JSON object:

{
  "type": "process-error",
  "error": {
    "event": "killed\n"
  }
}

Finally, we revert the chatgpt-send function to its original state:

(defun chatgpt-send ()
  "Send the current prompt to OpenAI."
  (interactive)
  (chatgpt-send-request (buffer-string))
  (erase-buffer)
  (when (> (length (window-list)) 1)
    (delete-window))
  (message "Request sent to OpenAI."))

chatgpt.el

The current implementation of the chatgpt.el package is as follows:

;;; chatgpt.el --- Simple ChatGPT integration -*- lexical-binding: t; -*-

(require 'json)
(require 'markdown-mode)

(defvar chatgpt-api-key
  "sk-proj-7pQDxN...w-D40A"
  "OpenAI API key.")

(defvar chatgpt-dir "/home/tony/chatgpt-emacs/requests/"
  "Request directory.")

(defun chatgpt-json-read ()
  (let ((json-key-type 'keyword)
        (json-object-type 'plist)
        (json-array-type 'vector))
    (json-read)))

(defun chatgpt-json-encode (object)
  (let ((json-encoding-pretty-print t))
    (json-encode object)))

(defun chatgpt-command (req-path)
  "Return the curl command."
  (format
   (concat "curl https://api.openai.com/v1/chat/completions "
           "-H 'Content-Type: application/json' "
           "-H 'Authorization: Bearer %s' "
           "-d @%s")
   chatgpt-api-key req-path))

(defvar chatgpt-model "gpt-4o"
  "OpenAI model.")

(defun chatgpt-request (prompt)
  "Return an OpenAI request with PROMPT."
  `(:model ,chatgpt-model
    :messages ,(vector `(:role "user" :content ,prompt))))

(defun chatgpt-callback (prompt response req-dir)
  "Append PROMPT and RESPONSE to the prompt buffer with a link to REQ-DIR."
  (let ((buff (get-buffer-create "*chatgpt[requests]*")))
    (with-current-buffer buff
      (markdown-mode)
      (goto-char (point-max))
      (insert "# Request\n\n"
              "<!-- [](" req-dir ") -->\n\n"
              "## Prompt\n\n" prompt "\n\n"
              "## Response\n\n" response "\n\n"))
    (with-selected-window (display-buffer buff nil)
      (goto-char (point-max))
      (re-search-backward "^## Response")
      (recenter-top-bottom 0))
    (message "Response received from OpenAI.")))

(defun chatgpt-send-request (prompt)
  "Send the request with PROMPT to OpenAI."
  (let* ((req (chatgpt-request prompt))
         (temporary-file-directory chatgpt-dir)
         (req-dir (file-name-as-directory (make-temp-file nil t)))
         (req-path (concat req-dir "request.json"))
         (command (chatgpt-command req-path)))
    (message "chatgpt: %s" req-dir)
    (write-region (chatgpt-json-encode req) nil req-path)
    (make-process
     :name "chatgpt"
     :buffer (generate-new-buffer-name "chatgpt")
     :command (list "sh" "-c" command)
     :sentinel
     (lambda (process event)
       (if (not (string= event "finished\n"))
           (let ((err `(:type "process-error" :error (:event ,event)))
                 (err-path (concat req-dir "error.json")))
             (write-region (chatgpt-json-encode err) nil err-path)
             (error "%S" err))
         (let* ((resp (with-current-buffer (process-buffer process)
                        (goto-char (point-min))
                        (chatgpt-json-read))))
           (if-let ((api-error (plist-get resp :error)))
               (let ((err `(:type "api-error" :error ,api-error))
                     (err-path (concat req-dir "error.json")))
                 (write-region (chatgpt-json-encode err) nil err-path)
                 (error "%S" err))
             (let ((response (map-nested-elt resp [:choices 0 :message :content]))
                   (resp-path (concat req-dir "response.json")))
               (write-region (chatgpt-json-encode resp) nil resp-path)
               (chatgpt-callback prompt response req-dir))))
         (kill-buffer (process-buffer process)))))))

(defun chatgpt-send ()
  "Send the current prompt to OpenAI."
  (interactive)
  (chatgpt-send-request (buffer-string))
  (erase-buffer)
  (when (> (length (window-list)) 1)
    (delete-window))
  (message "Request sent to OpenAI."))

(defvar chatgpt-mode-map
  (let ((map (make-sparse-keymap)))
    (define-key map (kbd "C-c C-c") 'chatgpt-send)
    map)
  "Keymap of `chatgpt-mode'.")

(define-derived-mode chatgpt-mode markdown-mode "ChatGPT"
  "ChatGPT mode."
  (setq mode-line-format
        '(" "
          mode-line-buffer-identification
          " "
          chatgpt-model
          " "
          mode-line-misc-info))
  (make-directory chatgpt-dir t))

(defun chatgpt ()
  "Display and Select the prompt buffer."
  (interactive)
  (let* ((buff-name "*chatgpt*")
         (buff-p (get-buffer buff-name))
         (buff (get-buffer-create buff-name)))
    (select-window
     (display-buffer-at-bottom
      buff '(display-buffer-below-selected (window-height . 6))))
    (when (not buff-p) (chatgpt-mode))))