From e19bb65e5bbb7f4dedbe849af6febad94b307809 Mon Sep 17 00:00:00 2001 From: Ewan Croft Date: Fri, 7 Aug 2026 23:53:31 +0100 Subject: [PATCH] fix(xrpc): record the server's error message on failed requests --- include/wolfram/xrpc.h | 9 +++++ src/transport/xrpc.c | 34 +++++++++++++++++++ src/transport/xrpc_wii.c | 38 ++++++++++++++++++++- test/test_xrpc.c | 71 ++++++++++++++++++++++++++++++++++++++++ 4 files changed, 151 insertions(+), 1 deletion(-) diff --git a/include/wolfram/xrpc.h b/include/wolfram/xrpc.h index ba06910..06effe4 100644 --- a/include/wolfram/xrpc.h +++ b/include/wolfram/xrpc.h @@ -246,6 +246,15 @@ void wf_response_free(wf_response *res); wf_status wf_xrpc_error(const wf_response *resp, char **out_error, char **out_message); +/** + * Returns the XRPC error message from the client's most recent request, or + * NULL if that request succeeded or carried no error envelope. The returned + * pointer is owned by the client and stays valid until the next request on + * the same client or until the client is freed — copy it if you need to keep + * it longer. + */ +const char *wf_xrpc_last_error(const wf_xrpc_client *client); + /** * Perform a generic HTTP GET with extra headers. * diff --git a/src/transport/xrpc.c b/src/transport/xrpc.c index 858519b..be2182d 100644 --- a/src/transport/xrpc.c +++ b/src/transport/xrpc.c @@ -44,6 +44,7 @@ struct wf_xrpc_client { int refreshing; /* re-entrancy guard while a refresh is in flight */ wf_tls_rng_fn tls_rng; /* NULL unless the application supplied one */ void *tls_rng_userdata; + char *last_error; /* XRPC error message from the last non-2xx response */ }; /* ── Application TLS RNG ─────────────────────────────────────────────── */ @@ -214,9 +215,14 @@ void wf_xrpc_client_free(wf_xrpc_client *client) { free(client->base_url); free(client->auth_header); free(client->ca_bundle); + free(client->last_error); free(client); } +const char *wf_xrpc_last_error(const wf_xrpc_client *client) { + return client ? client->last_error : NULL; +} + void wf_xrpc_set_handler(wf_xrpc_client *client, wf_xrpc_handler_fn fn, void *userdata) { if (!client) return; @@ -316,6 +322,30 @@ static void wf_http_headers_free(wf_http_header *arr, size_t count) { free(arr); } +/* Record the XRPC error envelope of a non-2xx response on the client so the + * caller can surface the server's message. Prefers `message`, falls back to + * the `error` code, and clears the field when the body has no envelope. */ +static void wf_xrpc_set_last_error(wf_xrpc_client *client, + const wf_response *out) { + if (!client || !out) return; + char *err = NULL, *msg = NULL; + if (wf_xrpc_error(out, &err, &msg) != WF_OK) { + free(client->last_error); + client->last_error = NULL; + return; + } + const char *chosen = (msg && *msg) ? msg : err; + if (chosen && chosen[0]) { + char *copy = strdup(chosen); + if (copy) { + free(client->last_error); + client->last_error = copy; + } + } + free(err); + free(msg); +} + /* * Single transport primitive shared by every request path. When a test handler * is installed it receives the fully-resolved request; otherwise the request is @@ -325,6 +355,8 @@ static wf_status wf_xrpc_perform(wf_xrpc_client *client, const char *method, const char *url, const char *content_type, const void *body, size_t body_len, struct curl_slist *headers, wf_response *out) { + free(client->last_error); + client->last_error = NULL; if (client->handler) { wf_http_header *harr = NULL; size_t hcount = 0; @@ -341,6 +373,7 @@ static wf_status wf_xrpc_perform(wf_xrpc_client *client, const char *method, (const char *)body, body_len, harr, hcount, out); wf_http_headers_free(harr, hcount); curl_slist_free_all(headers); + if (status == WF_ERR_HTTP) wf_xrpc_set_last_error(client, out); return status; } @@ -402,6 +435,7 @@ static wf_status wf_xrpc_perform(wf_xrpc_client *client, const char *method, if (http_status < 200 || http_status >= 300) { status = WF_ERR_HTTP; + wf_xrpc_set_last_error(client, out); } } diff --git a/src/transport/xrpc_wii.c b/src/transport/xrpc_wii.c index 5c0220d..3cce8c1 100644 --- a/src/transport/xrpc_wii.c +++ b/src/transport/xrpc_wii.c @@ -27,6 +27,7 @@ struct wf_xrpc_client { wf_xrpc_refresh_fn refresh_cb; void *refresh_userdata; int refreshing; + char *last_error; /* XRPC error message from the last non-2xx response */ }; /* ── Growable byte buffer ───────────────────────────────────────────── */ @@ -331,11 +332,37 @@ static wf_status wf_http_read_response(wii_tls_conn *conn, /* ── Core request ───────────────────────────────────────────────────── */ +/* Record the XRPC error envelope of a non-2xx response on the client so the + * caller can surface the server's message. Prefers `message`, falls back to + * the `error` code, and clears the field when the body has no envelope. */ +static void wf_xrpc_set_last_error(wf_xrpc_client *client, + const wf_response *out) { + if (!client || !out) return; + char *err = NULL, *msg = NULL; + if (wf_xrpc_error(out, &err, &msg) != WF_OK) { + free(client->last_error); + client->last_error = NULL; + return; + } + const char *chosen = (msg && *msg) ? msg : err; + if (chosen && chosen[0]) { + char *copy = strdup(chosen); + if (copy) { + free(client->last_error); + client->last_error = copy; + } + } + free(err); + free(msg); +} + static wf_status wf_xrpc_perform(wf_xrpc_client *client, const char *method, const char *url, const char *content_type, const void *body, size_t body_len, const wf_http_header *extra, size_t extra_count, wf_response *out) { + free(client->last_error); + client->last_error = NULL; memset(out, 0, sizeof(*out)); /* Test seam: a handler replaces real network I/O. */ @@ -360,6 +387,7 @@ static wf_status wf_xrpc_perform(wf_xrpc_client *client, const char *method, free((void *)harr[i].value); } free(harr); + if (s == WF_ERR_HTTP) wf_xrpc_set_last_error(client, out); return s; } @@ -456,7 +484,10 @@ static wf_status wf_xrpc_perform(wf_xrpc_client *client, const char *method, out->body_len = resp.len; out->dpop_nonce = nonce; - if (status < 200 || status >= 300) return WF_ERR_HTTP; + if (status < 200 || status >= 300) { + wf_xrpc_set_last_error(client, out); + return WF_ERR_HTTP; + } return WF_OK; } @@ -573,9 +604,14 @@ void wf_xrpc_client_free(wf_xrpc_client *client) { if (!client) return; free(client->base_url); free(client->auth_header); + free(client->last_error); free(client); } +const char *wf_xrpc_last_error(const wf_xrpc_client *client) { + return client ? client->last_error : NULL; +} + void wf_xrpc_set_handler(wf_xrpc_client *client, wf_xrpc_handler_fn fn, void *userdata) { if (!client) return; diff --git a/test/test_xrpc.c b/test/test_xrpc.c index 57203e9..43b2b3e 100644 --- a/test/test_xrpc.c +++ b/test/test_xrpc.c @@ -19,6 +19,36 @@ static int wf_test_tls_rng(void *userdata, unsigned char *output, size_t len) { return 0; } +/* Test seam handler: fails every request with the configured HTTP status and + * body, so the client's error capture can be exercised without a network. */ +struct wf_test_err_ctx { + int status; + const char *body; +}; + +static wf_status wf_test_error_handler(void *userdata, const char *method, + const char *url, + const char *content_type, + const char *body, size_t body_len, + const wf_http_header *headers, + size_t header_count, wf_response *out) { + (void)method; + (void)url; + (void)content_type; + (void)body; + (void)body_len; + (void)headers; + (void)header_count; + const struct wf_test_err_ctx *ctx = + (const struct wf_test_err_ctx *)userdata; + out->status = ctx->status; + if (ctx->body) { + out->body = strdup(ctx->body); + out->body_len = strlen(ctx->body); + } + return (ctx->status >= 200 && ctx->status < 300) ? WF_OK : WF_ERR_HTTP; +} + int main(void) { /* Rejects empty/NULL base URLs. */ WF_CHECK(wf_xrpc_client_new(NULL) == NULL); @@ -87,6 +117,47 @@ int main(void) { free(err); } + /* The error envelope of a non-2xx response is recorded on the client and + * exposed via wf_xrpc_last_error; a later successful request clears it. */ + { + wf_xrpc_client *c = wf_xrpc_client_new("https://eurosky.social"); + WF_CHECK(c != NULL); + WF_CHECK(wf_xrpc_last_error(c) == NULL); + + struct wf_test_err_ctx ctx = {.status = 400, + .body = + "{\"error\":\"InvalidRecord\"," + "\"message\":\"text is too long\"}"}; + wf_xrpc_set_handler(c, wf_test_error_handler, &ctx); + + wf_response res = {0}; + WF_CHECK(wf_xrpc_query(c, "com.atproto.repo.createRecord", NULL, + &res) == WF_ERR_HTTP); + wf_response_free(&res); + const char *le = wf_xrpc_last_error(c); + WF_CHECK(le && strcmp(le, "text is too long") == 0); + + /* A non-envelope failure body yields no message. */ + struct wf_test_err_ctx plain = {.status = 400, + .body = "{\"did\":\"x\"}"}; + wf_xrpc_set_handler(c, wf_test_error_handler, &plain); + WF_CHECK(wf_xrpc_query(c, "com.atproto.repo.describeRepo", NULL, + &res) == WF_ERR_HTTP); + wf_response_free(&res); + WF_CHECK(wf_xrpc_last_error(c) == NULL); + + /* Success clears the recorded error. */ + struct wf_test_err_ctx ok = {.status = 200, .body = "{\"ok\":true}"}; + wf_xrpc_set_handler(c, wf_test_error_handler, &ok); + WF_CHECK(wf_xrpc_query(c, "com.atproto.server.describeServer", NULL, + &res) == WF_OK); + wf_response_free(&res); + WF_CHECK(wf_xrpc_last_error(c) == NULL); + + wf_xrpc_set_handler(c, NULL, NULL); + wf_xrpc_client_free(c); + } + /* * Application TLS RNG. Whether one can be installed depends on the linked * libcurl's backend, which differs between a desktop build (usually -- 2.51.2