summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorThanos Apollo <public@thanosapollo.org>2026-04-28 20:25:53 +0300
committerThanos Apollo <public@thanosapollo.org>2026-04-28 20:25:53 +0300
commit1feef310af7d076bedf1f3d030a4c5ec87ea84da (patch)
tree69a54844f3f878cbc93c92ae7068c67d4756f73d
parent0f4866d93a55d824d824bca4eaa3825663d2b902 (diff)
api, db: Add rate limiting, conditional requests, and timeouts
-rw-r--r--lisp/forgejo-api.el320
-rw-r--r--lisp/forgejo-db.el103
-rw-r--r--tests/forgejo-test-api.el71
3 files changed, 390 insertions, 104 deletions
diff --git a/lisp/forgejo-api.el b/lisp/forgejo-api.el
index 2618a14..8c29081 100644
--- a/lisp/forgejo-api.el
+++ b/lisp/forgejo-api.el
@@ -36,6 +36,22 @@
(declare-function forgejo-token "forgejo.el" (host-url))
(defvar forgejo--api-default-limit)
+(defcustom forgejo-api-timeout 30
+ "Seconds before an async API request times out.
+When nil, no timeout is enforced."
+ :type '(choice (integer :tag "Seconds")
+ (const :tag "No timeout" nil))
+ :group 'forgejo)
+
+(defcustom forgejo-api-rate-limit-warn-threshold 10
+ "Warn when fewer than this many API requests remain."
+ :type 'integer
+ :group 'forgejo)
+
+(defvar forgejo-api--rate-limit-state (make-hash-table :test 'equal)
+ "Per-host rate limit state.
+Keys are host URLs, values are plists with :remaining, :limit, :reset.")
+
;;; URL building
(defun forgejo-api--url (host endpoint &optional params)
@@ -57,21 +73,37 @@ PARAMS is an alist of (KEY . VALUE) pairs for the query string."
;;; Response parsing
(defun forgejo-api--parse-headers (buffer)
- "Parse pagination headers from HTTP response BUFFER.
-Returns a plist with :total-count and :link."
+ "Parse pagination and rate-limit headers from HTTP response BUFFER.
+Returns a plist with :total-count, :link, :rate-limit-remaining,
+:rate-limit-limit, and :rate-limit-reset."
(with-current-buffer buffer
(save-excursion
(goto-char (point-min))
- (let (total-count link)
- (while (re-search-forward "^\\([^:]+\\): \\(.+\\)\r?$" nil t)
+ (forward-line 1)
+ (let (total-count link rl-remaining rl-limit rl-reset etag last-modified)
+ (while (re-search-forward "^\\([^:\n]+\\): \\(.+\\)\r?$" nil t)
(let ((name (downcase (match-string 1)))
- (value (match-string 2)))
+ (value (string-trim-right (match-string 2))))
(cond
((string= name "x-total-count")
(setq total-count (string-to-number value)))
((string= name "link")
- (setq link value)))))
- (list :total-count total-count :link link)))))
+ (setq link value))
+ ((string= name "x-ratelimit-remaining")
+ (setq rl-remaining (string-to-number value)))
+ ((string= name "x-ratelimit-limit")
+ (setq rl-limit (string-to-number value)))
+ ((string= name "x-ratelimit-reset")
+ (setq rl-reset (string-to-number value)))
+ ((string= name "etag")
+ (setq etag value))
+ ((string= name "last-modified")
+ (setq last-modified value)))))
+ (list :total-count total-count :link link
+ :rate-limit-remaining rl-remaining
+ :rate-limit-limit rl-limit
+ :rate-limit-reset rl-reset
+ :etag etag :last-modified last-modified)))))
(defun forgejo-api--parse-response (buffer)
"Parse the JSON body from HTTP response BUFFER.
@@ -89,10 +121,51 @@ Returns the parsed JSON as alists/lists, or nil for empty bodies."
(when (re-search-forward "^HTTP/[0-9.]+ \\([0-9]+\\)" nil t)
(string-to-number (match-string 1)))))
+;;; Rate limit helpers
+
+(defun forgejo-api--format-reset-time (unix-timestamp)
+ "Format UNIX-TIMESTAMP as a human-readable relative time string."
+ (if unix-timestamp
+ (let ((delta (- unix-timestamp (float-time))))
+ (if (> delta 0)
+ (format "in %d min" (ceiling (/ delta 60)))
+ "now"))
+ "unknown"))
+
+(defun forgejo-api--check-rate-limit (host headers)
+ "Update rate limit state for HOST from HEADERS, warn if low."
+ (let ((remaining (plist-get headers :rate-limit-remaining)))
+ (when remaining
+ (puthash host
+ (list :remaining remaining
+ :limit (plist-get headers :rate-limit-limit)
+ :reset (plist-get headers :rate-limit-reset))
+ forgejo-api--rate-limit-state)
+ (when (< remaining forgejo-api-rate-limit-warn-threshold)
+ (message "Forgejo API: %d requests remaining (resets %s)"
+ remaining
+ (forgejo-api--format-reset-time
+ (plist-get headers :rate-limit-reset)))))))
+
+(defun forgejo-api-rate-limit (host)
+ "Return the cached rate-limit plist for HOST, or nil."
+ (gethash host forgejo-api--rate-limit-state))
+
+;;; Error helpers
+
+(defun forgejo-api--error-plist (http-status method endpoint message data)
+ "Build a structured error plist from components.
+HTTP-STATUS is the numeric code (or nil for network errors).
+METHOD and ENDPOINT identify the request.
+MESSAGE is a human-readable error string.
+DATA is the parsed response body, if any."
+ (list :status http-status :method method :endpoint endpoint
+ :message message :data data))
+
;;; Core request
(defun forgejo-api--request (host method endpoint &optional params
- json-body callback)
+ json-body callback &rest args)
"Make an async HTTP request to the Forgejo API.
HOST is the instance base URL (e.g. \"https://codeberg.org\").
@@ -102,53 +175,121 @@ PARAMS is an alist of query parameters.
JSON-BODY, when non-nil, is an alist to encode as the request body.
CALLBACK is called with two arguments: (RESPONSE-DATA HEADERS-PLIST).
RESPONSE-DATA is the parsed JSON.
- HEADERS-PLIST contains :total-count and :link."
- (let ((url-request-method method)
- (url-request-extra-headers
- `(("Authorization" . ,(encode-coding-string
- (concat "token " (forgejo-token host))
- 'ascii))
- ("Accept" . "application/json")
- ,@(when json-body
- '(("Content-Type" . "application/json")))))
- (url-request-data
- (when json-body
- (encode-coding-string (json-encode json-body) 'utf-8)))
- (url (forgejo-api--url host endpoint params)))
- (url-retrieve
- url
- (lambda (status)
- (unwind-protect
- (let ((http-status (forgejo-api--response-status (current-buffer))))
- (cond
- ((and http-status (>= http-status 400))
- (let* ((err-data (condition-case nil
- (forgejo-api--parse-response (current-buffer))
- (json-parse-error nil)))
- (err-msg (when (listp err-data)
- (alist-get 'message err-data))))
- (message "Forgejo API HTTP %d: %s %s%s"
- http-status method endpoint
- (if err-msg (concat " - " err-msg) ""))))
- ((plist-get status :error)
- (message "Forgejo API error: %S" (plist-get status :error)))
- (t
- (when callback
- (let ((headers (forgejo-api--parse-headers (current-buffer)))
- (data (forgejo-api--parse-response (current-buffer))))
- (funcall callback data headers))))))
- (kill-buffer (current-buffer))))
- nil t)))
+ HEADERS-PLIST contains :total-count and :link.
+
+ARGS is a plist of keyword options:
+ :error-callback -- function called with an error plist on failure.
+ The plist contains :status, :method, :endpoint, :message, :data.
+ When omitted, errors are logged to *Messages*.
+ :if-none-match -- ETag value for conditional requests.
+ :if-modified-since -- date string for conditional requests.
+ On 304 Not Modified, CALLBACK receives nil data."
+ (let* ((error-callback (plist-get args :error-callback))
+ (if-none-match (plist-get args :if-none-match))
+ (if-modified-since (plist-get args :if-modified-since))
+ (completed (cons nil nil))
+ (timeout-timer nil)
+ (url-request-method method)
+ (url-request-extra-headers
+ `(("Authorization" . ,(encode-coding-string
+ (concat "token " (forgejo-token host))
+ 'ascii))
+ ("Accept" . "application/json")
+ ,@(when json-body
+ '(("Content-Type" . "application/json")))
+ ,@(when if-none-match
+ `(("If-None-Match" . ,if-none-match)))
+ ,@(when if-modified-since
+ `(("If-Modified-Since" . ,if-modified-since)))))
+ (url-request-data
+ (when json-body
+ (encode-coding-string (json-encode json-body) 'utf-8)))
+ (url (forgejo-api--url host endpoint params))
+ (url-buf
+ (url-retrieve
+ url
+ (lambda (status)
+ (setcar completed t)
+ (when timeout-timer (cancel-timer timeout-timer))
+ (unwind-protect
+ (let ((http-status (forgejo-api--response-status
+ (current-buffer))))
+ (cond
+ ((and http-status (>= http-status 400))
+ (forgejo-api--check-rate-limit
+ host (forgejo-api--parse-headers (current-buffer)))
+ (let* ((err-data (condition-case nil
+ (forgejo-api--parse-response
+ (current-buffer))
+ (json-parse-error nil)))
+ (err-msg (when (listp err-data)
+ (alist-get 'message err-data)))
+ (error-info (forgejo-api--error-plist
+ http-status method endpoint
+ err-msg err-data)))
+ (if error-callback
+ (funcall error-callback error-info)
+ (message "Forgejo API HTTP %d: %s %s%s"
+ http-status method endpoint
+ (if err-msg
+ (concat " - " err-msg) "")))))
+ ((plist-get status :error)
+ (let ((error-info
+ (forgejo-api--error-plist
+ nil method endpoint
+ (format "%S" (plist-get status :error))
+ nil)))
+ (if error-callback
+ (funcall error-callback error-info)
+ (message "Forgejo API error: %S"
+ (plist-get status :error)))))
+ ((and http-status (= http-status 304))
+ (let ((headers (forgejo-api--parse-headers
+ (current-buffer))))
+ (forgejo-api--check-rate-limit host headers)
+ (when callback
+ (funcall callback nil headers))))
+ (t
+ (let ((headers (forgejo-api--parse-headers
+ (current-buffer)))
+ (data (forgejo-api--parse-response
+ (current-buffer))))
+ (forgejo-api--check-rate-limit host headers)
+ (when callback
+ (funcall callback data headers))))))
+ (kill-buffer (current-buffer))))
+ nil t)))
+ (when (and forgejo-api-timeout url-buf)
+ (setq timeout-timer
+ (run-at-time
+ forgejo-api-timeout nil
+ (lambda ()
+ (unless (car completed)
+ (setcar completed t)
+ (when (buffer-live-p url-buf)
+ (let ((proc (get-buffer-process url-buf)))
+ (when proc (delete-process proc)))
+ (kill-buffer url-buf))
+ (let ((error-info (forgejo-api--error-plist
+ nil method endpoint
+ "Request timed out" nil)))
+ (if error-callback
+ (funcall error-callback error-info)
+ (message "Forgejo API timeout: %s %s"
+ method endpoint))))))))))
;;; Public wrappers
-(defun forgejo-api-get (host endpoint &optional params callback)
- "GET ENDPOINT on HOST with query PARAMS, call CALLBACK with (data headers)."
- (forgejo-api--request host "GET" endpoint params nil callback))
+(defun forgejo-api-get (host endpoint &optional params callback &rest args)
+ "GET ENDPOINT on HOST with query PARAMS, call CALLBACK with (data headers).
+ARGS accepts :error-callback for failure handling."
+ (apply #'forgejo-api--request host "GET" endpoint params nil callback args))
(defun forgejo-api-get-all (host endpoint &optional params callback)
"GET all pages from ENDPOINT on HOST, call CALLBACK with (all-data headers).
Fetches pages sequentially until all results are collected.
+On mid-pagination failure, calls CALLBACK with partial data and
+headers tagged with :partial t.
PARAMS should include a \"limit\" entry. The \"page\" param is
managed automatically."
(let ((limit (or (cdr (assoc "limit" params)) "30"))
@@ -170,14 +311,22 @@ managed automatically."
(setq page (1+ page))
(fetch-page))
(when callback
- (funcall callback accum headers)))))))))
+ (funcall callback accum headers)))))
+ :error-callback
+ (lambda (error-info)
+ (when callback
+ (funcall callback accum
+ (list :total-count nil :link nil
+ :partial t :error error-info))))))))
(fetch-page))))
(defun forgejo-api-get-paged (host endpoint params page-callback
&optional done-callback)
"GET all pages from ENDPOINT on HOST, calling PAGE-CALLBACK after each.
PAGE-CALLBACK receives (PAGE-DATA HEADERS PAGE-NUMBER).
-DONE-CALLBACK receives (ALL-DATA HEADERS) when all pages are fetched."
+DONE-CALLBACK receives (ALL-DATA HEADERS) when all pages are fetched.
+On mid-pagination failure, calls DONE-CALLBACK with partial data and
+headers tagged with :partial t."
(let ((limit (or (cdr (assoc "limit" params)) "50"))
(accum nil)
(page 1))
@@ -199,24 +348,69 @@ DONE-CALLBACK receives (ALL-DATA HEADERS) when all pages are fetched."
(setq page (1+ page))
(fetch-page))
(when done-callback
- (funcall done-callback accum headers)))))))))
+ (funcall done-callback accum headers)))))
+ :error-callback
+ (lambda (error-info)
+ (when done-callback
+ (funcall done-callback accum
+ (list :total-count nil :link nil
+ :partial t :error error-info))))))))
(fetch-page))))
-(defun forgejo-api-post (host endpoint &optional params json-body callback)
- "POST to ENDPOINT on HOST with PARAMS and JSON-BODY, call CALLBACK."
- (forgejo-api--request host "POST" endpoint params json-body callback))
+(defun forgejo-api-post (host endpoint &optional params json-body callback
+ &rest args)
+ "POST to ENDPOINT on HOST with PARAMS and JSON-BODY, call CALLBACK.
+ARGS accepts :error-callback for failure handling."
+ (apply #'forgejo-api--request host "POST" endpoint params json-body
+ callback args))
+
+(defun forgejo-api-patch (host endpoint &optional json-body callback &rest args)
+ "PATCH ENDPOINT on HOST with JSON-BODY, call CALLBACK.
+ARGS accepts :error-callback for failure handling."
+ (apply #'forgejo-api--request host "PATCH" endpoint nil json-body
+ callback args))
+
+(defun forgejo-api-put (host endpoint &optional json-body callback &rest args)
+ "PUT ENDPOINT on HOST with JSON-BODY, call CALLBACK.
+ARGS accepts :error-callback for failure handling."
+ (apply #'forgejo-api--request host "PUT" endpoint nil json-body
+ callback args))
+
+(defun forgejo-api-delete (host endpoint &optional json-body callback &rest args)
+ "DELETE ENDPOINT on HOST with optional JSON-BODY, call CALLBACK.
+ARGS accepts :error-callback for failure handling."
+ (apply #'forgejo-api--request host "DELETE" endpoint nil json-body
+ callback args))
-(defun forgejo-api-patch (host endpoint &optional json-body callback)
- "PATCH ENDPOINT on HOST with JSON-BODY, call CALLBACK."
- (forgejo-api--request host "PATCH" endpoint nil json-body callback))
+;;; Conditional requests
-(defun forgejo-api-put (host endpoint &optional json-body callback)
- "PUT ENDPOINT on HOST with JSON-BODY, call CALLBACK."
- (forgejo-api--request host "PUT" endpoint nil json-body callback))
+(declare-function forgejo-db-get-cache-headers "forgejo-db.el"
+ (host owner repo endpoint))
+(declare-function forgejo-db-set-cache-headers "forgejo-db.el"
+ (host owner repo endpoint etag last-modified))
-(defun forgejo-api-delete (host endpoint &optional json-body callback)
- "DELETE ENDPOINT on HOST with optional JSON-BODY, call CALLBACK."
- (forgejo-api--request host "DELETE" endpoint nil json-body callback))
+(defun forgejo-api-get-conditional (host endpoint params
+ cache-key callback &rest args)
+ "GET with conditional headers from DB cache.
+CACHE-KEY is a list (HOST OWNER REPO ENDPOINT-NAME) for cache lookup.
+On 304, calls CALLBACK with nil data. On 200, updates cached headers.
+ARGS are passed through to `forgejo-api-get'."
+ (let* ((cached (apply #'forgejo-db-get-cache-headers cache-key))
+ (etag (plist-get cached :etag))
+ (last-mod (plist-get cached :last-modified)))
+ (apply #'forgejo-api-get host endpoint params
+ (lambda (data headers)
+ (when data
+ (let ((new-etag (plist-get headers :etag))
+ (new-last-mod (plist-get headers :last-modified)))
+ (when (or new-etag new-last-mod)
+ (apply #'forgejo-db-set-cache-headers
+ (append cache-key
+ (list new-etag new-last-mod))))))
+ (when callback (funcall callback data headers)))
+ :if-none-match etag
+ :if-modified-since last-mod
+ args)))
;;; Instance settings
diff --git a/lisp/forgejo-db.el b/lisp/forgejo-db.el
index be45cdf..22ce1b1 100644
--- a/lisp/forgejo-db.el
+++ b/lisp/forgejo-db.el
@@ -102,6 +102,8 @@
repo TEXT NOT NULL,
endpoint TEXT NOT NULL,
last_synced TEXT,
+ etag TEXT,
+ last_modified TEXT,
PRIMARY KEY (host, owner, repo, endpoint))"
)
"SQL statements to initialize the database schema.")
@@ -210,21 +212,21 @@ When IS-PULL is non-nil, mark all entries as pull requests regardless of
whether a `pull_request' field is present in the data."
(setq owner (downcase owner) repo (downcase repo))
(forgejo-db--with-transaction
- (let ((db (forgejo-db--ensure)))
- (dolist (issue issues)
- (let-alist issue
- (let ((body (forgejo-db--nullable .body))
- (labels (forgejo-db--nullable .labels))
- (milestone (forgejo-db--nullable .milestone))
- (assignees (forgejo-db--nullable .assignees))
- (closed (forgejo-db--nullable .closed_at))
- (pr (or is-pull (forgejo-db--nullable .pull_request))))
- ;; Track edits before overwriting
- (when body
- (forgejo-db--track-edit db host owner repo .number body))
- (sqlite-execute
- db
- "INSERT INTO issues
+ (let ((db (forgejo-db--ensure)))
+ (dolist (issue issues)
+ (let-alist issue
+ (let ((body (forgejo-db--nullable .body))
+ (labels (forgejo-db--nullable .labels))
+ (milestone (forgejo-db--nullable .milestone))
+ (assignees (forgejo-db--nullable .assignees))
+ (closed (forgejo-db--nullable .closed_at))
+ (pr (or is-pull (forgejo-db--nullable .pull_request))))
+ ;; Track edits before overwriting
+ (when body
+ (forgejo-db--track-edit db host owner repo .number body))
+ (sqlite-execute
+ db
+ "INSERT INTO issues
(id, host, owner, repo, number, title, state, body,
user, labels, milestone, assignees, comments_count,
created_at, updated_at, closed_at, is_pull, read)
@@ -235,16 +237,16 @@ whether a `pull_request' field is present in the data."
assignees=excluded.assignees, comments_count=excluded.comments_count,
updated_at=excluded.updated_at, closed_at=excluded.closed_at,
is_pull=excluded.is_pull, read=0"
- (list .id host owner repo .number .title .state body
- (alist-get 'login .user)
- (forgejo-db--encode-json (when (listp labels) labels))
- (when (listp milestone) (alist-get 'title milestone))
- (forgejo-db--encode-json
- (when (listp assignees)
- (mapcar (lambda (a) (alist-get 'login a)) assignees)))
- .comments
- .created_at .updated_at closed
- (if pr 1 0)))))))))
+ (list .id host owner repo .number .title .state body
+ (alist-get 'login .user)
+ (forgejo-db--encode-json (when (listp labels) labels))
+ (when (listp milestone) (alist-get 'title milestone))
+ (forgejo-db--encode-json
+ (when (listp assignees)
+ (mapcar (lambda (a) (alist-get 'login a)) assignees)))
+ .comments
+ .created_at .updated_at closed
+ (if pr 1 0)))))))))
(defun forgejo-db--like (s)
"Wrap S in SQL LIKE wildcards."
@@ -321,12 +323,12 @@ FILTERS is a plist with keys:
(setq owner (downcase owner) repo (downcase repo))
(when (and events (listp events))
(forgejo-db--with-transaction
- (let ((db (forgejo-db--ensure)))
- (dolist (event events)
- (let-alist event
- (sqlite-execute
- db
- "INSERT INTO timeline_events
+ (let ((db (forgejo-db--ensure)))
+ (dolist (event events)
+ (let-alist event
+ (sqlite-execute
+ db
+ "INSERT INTO timeline_events
(id, host, owner, repo, issue_number, type, body,
user, created_at, data)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
@@ -334,12 +336,12 @@ FILTERS is a plist with keys:
type=excluded.type, body=excluded.body,
user=excluded.user, created_at=excluded.created_at,
data=excluded.data"
- (list .id host owner repo number
- (forgejo-db--nullable .type)
- (forgejo-db--nullable .body)
- (alist-get 'login (forgejo-db--nullable .user))
- (forgejo-db--nullable .created_at)
- (forgejo-db--encode-json event)))))))))
+ (list .id host owner repo number
+ (forgejo-db--nullable .type)
+ (forgejo-db--nullable .body)
+ (alist-get 'login (forgejo-db--nullable .user))
+ (forgejo-db--nullable .created_at)
+ (forgejo-db--encode-json event)))))))))
(defun forgejo-db-get-timeline (host owner repo number)
"Get cached timeline events for issue NUMBER in HOST/OWNER/REPO."
@@ -458,11 +460,32 @@ FILTERS is a plist with keys:
"Set the last sync TIME for ENDPOINT in HOST/OWNER/REPO."
(setq owner (downcase owner) repo (downcase repo))
(forgejo-db--execute
- "INSERT OR REPLACE INTO sync_state
- (host, owner, repo, endpoint, last_synced)
- VALUES (?, ?, ?, ?, ?)"
+ "INSERT INTO sync_state (host, owner, repo, endpoint, last_synced)
+ VALUES (?, ?, ?, ?, ?)
+ ON CONFLICT (host, owner, repo, endpoint)
+ DO UPDATE SET last_synced = excluded.last_synced"
(list host owner repo endpoint time)))
+(defun forgejo-db-get-cache-headers (host owner repo endpoint)
+ "Get cached ETag and Last-Modified for ENDPOINT in HOST/OWNER/REPO."
+ (setq owner (downcase owner) repo (downcase repo))
+ (let ((row (car (forgejo-db--select
+ "SELECT etag, last_modified FROM sync_state
+ WHERE host = ? AND owner = ? AND repo = ? AND endpoint = ?"
+ (list host owner repo endpoint)))))
+ (when row
+ (list :etag (nth 0 row) :last-modified (nth 1 row)))))
+
+(defun forgejo-db-set-cache-headers (host owner repo endpoint etag last-modified)
+ "Update cached ETAG and LAST-MODIFIED for ENDPOINT in HOST/OWNER/REPO."
+ (setq owner (downcase owner) repo (downcase repo))
+ (forgejo-db--execute
+ "INSERT INTO sync_state (host, owner, repo, endpoint, etag, last_modified)
+ VALUES (?, ?, ?, ?, ?, ?)
+ ON CONFLICT (host, owner, repo, endpoint)
+ DO UPDATE SET etag = excluded.etag, last_modified = excluded.last_modified"
+ (list host owner repo endpoint etag last-modified)))
+
;;; Row-to-alist conversion (explicit column queries)
(defconst forgejo-db--issue-columns
diff --git a/tests/forgejo-test-api.el b/tests/forgejo-test-api.el
index c93b12e..06baefe 100644
--- a/tests/forgejo-test-api.el
+++ b/tests/forgejo-test-api.el
@@ -100,6 +100,23 @@
(insert "HTTP/1.1 200 OK\r\n\r\n{}")
(should (= (forgejo-api--response-status (current-buffer)) 200))))
+(ert-deftest forgejo-test-api-response-status-304 ()
+ "Extract 304 status code for conditional requests."
+ (with-temp-buffer
+ (insert "HTTP/1.1 304 Not Modified\r\n\r\n")
+ (should (= (forgejo-api--response-status (current-buffer)) 304))))
+
+(ert-deftest forgejo-test-api-parse-headers-etag ()
+ "Parse ETag and Last-Modified headers."
+ (with-temp-buffer
+ (insert "HTTP/1.1 200 OK\r\n"
+ "ETag: \"abc123\"\r\n"
+ "Last-Modified: Tue, 01 Apr 2026 12:00:00 GMT\r\n"
+ "\r\n{}")
+ (let ((headers (forgejo-api--parse-headers (current-buffer))))
+ (should (string= (plist-get headers :etag) "\"abc123\""))
+ (should (string-match-p "Tue" (plist-get headers :last-modified))))))
+
;;; Group 5: Auth
(ert-deftest forgejo-test-api-token-from-variable ()
@@ -116,7 +133,59 @@
(forgejo-token nil))
(should-error (forgejo-token "https://codeberg.org") :type 'user-error)))
-;;; Group 6: Default limit
+;;; Group 6: Header parsing with rate limits
+
+(ert-deftest forgejo-test-api-parse-headers-rate-limit ()
+ "Parse rate limit headers from HTTP response."
+ (with-temp-buffer
+ (insert "HTTP/1.1 200 OK\r\n"
+ "X-Total-Count: 10\r\n"
+ "X-RateLimit-Remaining: 42\r\n"
+ "X-RateLimit-Limit: 300\r\n"
+ "X-RateLimit-Reset: 1700000000\r\n"
+ "\r\n{}")
+ (let ((headers (forgejo-api--parse-headers (current-buffer))))
+ (should (= (plist-get headers :total-count) 10))
+ (should (= (plist-get headers :rate-limit-remaining) 42))
+ (should (= (plist-get headers :rate-limit-limit) 300))
+ (should (= (plist-get headers :rate-limit-reset) 1700000000)))))
+
+;;; Group 7: Rate limit helpers
+
+(ert-deftest forgejo-test-api-format-reset-time-future ()
+ "Format a future reset timestamp as relative minutes."
+ (let ((future (+ (float-time) 300)))
+ (should (string-match-p "in [0-9]+ min"
+ (forgejo-api--format-reset-time future)))))
+
+(ert-deftest forgejo-test-api-format-reset-time-past ()
+ "Format a past reset timestamp as now."
+ (let ((past (- (float-time) 10)))
+ (should (string= "now" (forgejo-api--format-reset-time past)))))
+
+(ert-deftest forgejo-test-api-format-reset-time-nil ()
+ "Format nil timestamp as unknown."
+ (should (string= "unknown" (forgejo-api--format-reset-time nil))))
+
+;;; Group 8: Error plist construction
+
+(ert-deftest forgejo-test-api-error-plist ()
+ "Build a structured error plist from components."
+ (let ((plist (forgejo-api--error-plist 404 "GET" "repos/x/y" "Not Found" nil)))
+ (should (= (plist-get plist :status) 404))
+ (should (string= (plist-get plist :method) "GET"))
+ (should (string= (plist-get plist :endpoint) "repos/x/y"))
+ (should (string= (plist-get plist :message) "Not Found"))
+ (should (null (plist-get plist :data)))))
+
+(ert-deftest forgejo-test-api-error-plist-network ()
+ "Error plist for network errors has nil status."
+ (let ((plist (forgejo-api--error-plist nil "POST" "repos/x/y/issues"
+ "connection refused" nil)))
+ (should (null (plist-get plist :status)))
+ (should (string= (plist-get plist :message) "connection refused"))))
+
+;;; Group 9: Default limit
(ert-deftest forgejo-test-api-default-limit-cached ()
"Return cached limit when available."