Skip to main content

notedthat_api_http/
error.rs

1//! HTTP API error types and JSON envelope.
2
3use axum::Json;
4use axum::http::header::{CONTENT_RANGE, RETRY_AFTER};
5use axum::http::{HeaderName, StatusCode};
6use axum::response::{IntoResponse, Response};
7use notedthat_core::{Error as CoreError, StorageError};
8use serde::Serialize;
9
10/// HTTP-layer error — maps domain and storage errors to HTTP status codes.
11#[derive(Debug, thiserror::Error)]
12pub enum ApiError {
13    /// The request lacked valid Bearer credentials.
14    #[error("unauthorized")]
15    Unauthorized,
16    /// The credential is valid, but the knowledge base's access policy does not
17    /// grant this operation on this key.
18    ///
19    /// Distinct from [`Self::Unauthorized`] on purpose: `401` tells a caller
20    /// that credentials might help, and `403` tells them that theirs are fine
21    /// and the answer is still no. D43 reserved this code for exactly this.
22    #[error("forbidden")]
23    Forbidden,
24    /// Indexer queue was full while enqueueing an upsert (PUT/COPY).
25    #[error("indexer upsert backpressure")]
26    IndexerBackpressureUpsert,
27    /// Indexer queue was full while enqueueing a tombstone (DELETE).
28    #[error("indexer tombstone backpressure")]
29    IndexerBackpressureTombstone,
30    /// The change event could not be published after storage had already
31    /// taken the write (D55). Answered like indexer backpressure: 503 with
32    /// `Retry-After`, so the client retries the idempotent write.
33    #[error("change event not published after the object was {after}")]
34    EventPublishFailed {
35        /// What storage had already done.
36        after: notedthat_write::WriteEffect,
37    },
38    /// A `Last-Event-ID` older than what the event log retains.
39    #[error("events after {requested} are no longer retained; oldest retained is {oldest}")]
40    EventsGone {
41        /// The position the subscriber asked to resume after.
42        requested: notedthat_core::EventId,
43        /// The oldest position still held.
44        oldest: notedthat_core::EventId,
45    },
46    /// The event log could not be subscribed to.
47    #[error("event backend unavailable: {message}")]
48    EventsUnavailable {
49        /// What the adapter reported.
50        message: String,
51    },
52    /// A domain error from `notedthat-core`.
53    #[error(transparent)]
54    Core(#[from] CoreError),
55    /// A storage-layer error not otherwise promoted to a top-level variant.
56    ///
57    /// Note: `StorageError::NotModified`, `StorageError::PreconditionFailed`, and
58    /// `StorageError::RangeNotSatisfiable` are promoted via the manual `From<StorageError>`
59    /// impl to top-level `ApiError` variants so that `IntoResponse` can emit the
60    /// RFC-required headers (e.g. `Content-Range: bytes */N` for 416).
61    #[error(transparent)]
62    Storage(StorageError),
63    /// A conditional PUT/DELETE failed because a precondition was not met.
64    ///
65    /// Maps to HTTP 412 Precondition Failed.
66    #[error("precondition failed")]
67    PreconditionFailed,
68    /// The requested byte range could not be satisfied.
69    ///
70    /// `complete_length` is the total object size in bytes.  The `IntoResponse`
71    /// impl emits `Content-Range: bytes */complete_length` per RFC 7233 §4.4.
72    #[error("range not satisfiable")]
73    RangeNotSatisfiable {
74        /// Total object size in bytes.
75        complete_length: u64,
76    },
77    /// The backend returned 304 Not Modified for a conditional GET/HEAD.
78    ///
79    /// Maps to HTTP 304 with no body, as required by RFC 7232 §4.1.
80    #[error("not modified")]
81    NotModified,
82    /// The `Range:` header value could not be parsed per RFC 7233.
83    ///
84    /// Maps to HTTP 400 Bad Request.
85    #[error("malformed range: {0}")]
86    MalformedRange(String),
87    /// A `Range: lines=…` header specified a range that exceeds the object's line count.
88    ///
89    /// Maps to HTTP 416 with `Content-Range: lines */<line_total>` and
90    /// `X-Content-Range-Bytes: */<byte_total>` per the line-range extension.
91    #[error("line range not satisfiable")]
92    LineRangeNotSatisfiable {
93        /// Total line count in the object.
94        line_total: u64,
95        /// Total byte count in the object.
96        byte_total: u64,
97    },
98    /// A single-replace operation found no occurrence of `old_string`.
99    #[error("no match found for old_string")]
100    ReplaceNoMatch,
101    /// A single-replace operation found multiple occurrences of `old_string`.
102    #[error("multiple matches found ({count}); use replace_all to replace them all")]
103    ReplaceAmbiguous {
104        /// Number of occurrences found.
105        count: u64,
106    },
107}
108
109/// Promote specific `StorageError` variants to top-level `ApiError` variants so
110/// that `IntoResponse` can emit the RFC-mandated response headers.
111impl From<StorageError> for ApiError {
112    fn from(e: StorageError) -> Self {
113        match e {
114            StorageError::NotModified => Self::NotModified,
115            StorageError::PreconditionFailed => Self::PreconditionFailed,
116            StorageError::RangeNotSatisfiable { complete_length } => {
117                Self::RangeNotSatisfiable { complete_length }
118            }
119            other => Self::Storage(other),
120        }
121    }
122}
123
124impl From<notedthat_write::WriteError> for ApiError {
125    fn from(e: notedthat_write::WriteError) -> Self {
126        match e {
127            notedthat_write::WriteError::Storage(e) => Self::Storage(e),
128            notedthat_write::WriteError::TooLarge { size, limit }
129            | notedthat_write::WriteError::PatchTooLarge { size, limit } => {
130                Self::Core(CoreError::PayloadTooLarge { size, limit })
131            }
132            notedthat_write::WriteError::Path(e) => Self::Core(e),
133            notedthat_write::WriteError::IndexerBackpressureUpsert => {
134                Self::IndexerBackpressureUpsert
135            }
136            notedthat_write::WriteError::IndexerBackpressureTombstone => {
137                Self::IndexerBackpressureTombstone
138            }
139            notedthat_write::WriteError::EventPublishFailed { after } => {
140                Self::EventPublishFailed { after }
141            }
142            notedthat_write::WriteError::PatchLineOutOfRange {
143                total_lines,
144                total_bytes,
145                ..
146            } => Self::LineRangeNotSatisfiable {
147                line_total: total_lines,
148                byte_total: total_bytes,
149            },
150            notedthat_write::WriteError::PatchInvalidRange { message } => {
151                Self::Core(CoreError::InvalidInput { message })
152            }
153            notedthat_write::WriteError::ReplaceNoMatch => Self::ReplaceNoMatch,
154            notedthat_write::WriteError::ReplaceAmbiguous { count } => {
155                Self::ReplaceAmbiguous { count }
156            }
157        }
158    }
159}
160
161/// JSON error response body shape: `{ "error": "code", "message": "...", "request_id": "..." }`.
162#[derive(Serialize)]
163struct ErrorBody<'a> {
164    error: &'a str,
165    message: String,
166    request_id: String,
167}
168
169#[derive(Serialize)]
170struct ReplaceAmbiguousBody<'a> {
171    error: &'a str,
172    message: String,
173    request_id: String,
174    match_count: u64,
175}
176
177/// An [`ApiError`] paired with a `request_id` string so the JSON body and the
178/// `x-request-id` response header both contain the same value.
179pub struct ApiErrorResponse {
180    /// The underlying error.
181    pub error: ApiError,
182    /// The request ID (from the `x-request-id` header via `tower-http`).
183    pub request_id: String,
184}
185
186impl ApiErrorResponse {
187    /// Build an unauthorized response with the provided request ID.
188    #[must_use]
189    pub fn unauthorized(request_id: String) -> Self {
190        Self {
191            error: ApiError::Unauthorized,
192            request_id,
193        }
194    }
195}
196
197impl ApiError {
198    fn status_and_code(&self) -> (StatusCode, &'static str) {
199        match self {
200            Self::Unauthorized => (StatusCode::UNAUTHORIZED, "unauthorized"),
201            Self::Forbidden => (StatusCode::FORBIDDEN, "forbidden"),
202            Self::IndexerBackpressureUpsert
203            | Self::IndexerBackpressureTombstone
204            | Self::EventPublishFailed { .. }
205            | Self::EventsUnavailable { .. } => {
206                (StatusCode::SERVICE_UNAVAILABLE, "backend_unavailable")
207            }
208            Self::EventsGone { .. } => (StatusCode::GONE, "gone"),
209            Self::Core(CoreError::InvalidInput { .. }) => {
210                (StatusCode::BAD_REQUEST, "invalid_request")
211            }
212            Self::Core(CoreError::NotFound { .. }) => (StatusCode::NOT_FOUND, "not_found"),
213            Self::Core(CoreError::PayloadTooLarge { .. }) => {
214                (StatusCode::PAYLOAD_TOO_LARGE, "payload_too_large")
215            }
216            Self::Core(CoreError::MalformedRange(_)) | Self::MalformedRange(_) => {
217                (StatusCode::BAD_REQUEST, "malformed_range")
218            }
219            Self::LineRangeNotSatisfiable { .. }
220            | Self::Core(CoreError::RangeNotSatisfiable { .. })
221            | Self::RangeNotSatisfiable { .. } => {
222                (StatusCode::RANGE_NOT_SATISFIABLE, "range_not_satisfiable")
223            }
224            Self::Core(CoreError::NotModified) | Self::NotModified => {
225                (StatusCode::NOT_MODIFIED, "not_modified")
226            }
227            Self::Core(CoreError::PreconditionFailed) | Self::PreconditionFailed => {
228                (StatusCode::PRECONDITION_FAILED, "precondition_failed")
229            }
230            Self::ReplaceNoMatch => (StatusCode::UNPROCESSABLE_ENTITY, "no_match"),
231            Self::ReplaceAmbiguous { .. } => (StatusCode::UNPROCESSABLE_ENTITY, "ambiguous_match"),
232            Self::Core(CoreError::BucketNameTooLong { .. } | CoreError::Config { .. }) => {
233                (StatusCode::INTERNAL_SERVER_ERROR, "internal_error")
234            }
235            Self::Core(CoreError::Storage(e)) | Self::Storage(e) => Self::storage_status(e),
236        }
237    }
238
239    fn storage_status(e: &StorageError) -> (StatusCode, &'static str) {
240        match e {
241            StorageError::NotFound { .. } | StorageError::BucketNotFound { .. } => {
242                (StatusCode::NOT_FOUND, "not_found")
243            }
244            StorageError::BackendUnavailable { .. } => {
245                (StatusCode::SERVICE_UNAVAILABLE, "backend_unavailable")
246            }
247            StorageError::NotModified => (StatusCode::NOT_MODIFIED, "not_modified"),
248            StorageError::PreconditionFailed => {
249                (StatusCode::PRECONDITION_FAILED, "precondition_failed")
250            }
251            StorageError::RangeNotSatisfiable { .. } => {
252                (StatusCode::RANGE_NOT_SATISFIABLE, "range_not_satisfiable")
253            }
254            StorageError::Other { .. } => (StatusCode::INTERNAL_SERVER_ERROR, "internal_error"),
255        }
256    }
257
258    /// Extract the `complete_length` for a 416 response, regardless of which
259    /// wrapper the `RangeNotSatisfiable` error arrived in.
260    fn range_not_satisfiable_length(&self) -> Option<u64> {
261        match self {
262            Self::RangeNotSatisfiable { complete_length }
263            | Self::Storage(StorageError::RangeNotSatisfiable { complete_length })
264            | Self::Core(CoreError::RangeNotSatisfiable { complete_length }) => {
265                Some(*complete_length)
266            }
267            _ => None,
268        }
269    }
270
271    /// Return `true` for all variants that map to HTTP 304 (empty body required).
272    fn is_not_modified(&self) -> bool {
273        matches!(
274            self,
275            Self::NotModified
276                | Self::Storage(StorageError::NotModified)
277                | Self::Core(CoreError::NotModified)
278        )
279    }
280}
281
282impl ApiErrorResponse {
283    /// The D38 shape: storage already did the work, a queue behind it did not,
284    /// and the idempotent request should simply be repeated.
285    fn retry_later(request_id: String, message: String) -> Response {
286        let body = ErrorBody {
287            error: "backend_unavailable",
288            message,
289            request_id,
290        };
291        (
292            StatusCode::SERVICE_UNAVAILABLE,
293            [(RETRY_AFTER, "5")],
294            Json(body),
295        )
296            .into_response()
297    }
298}
299
300impl IntoResponse for ApiErrorResponse {
301    fn into_response(self) -> Response {
302        // Line-mode 416: emit Content-Range: lines */<total> + X-Content-Range-Bytes: */<total_bytes>.
303        if let ApiError::LineRangeNotSatisfiable {
304            line_total,
305            byte_total,
306        } = &self.error
307        {
308            return (
309                StatusCode::RANGE_NOT_SATISFIABLE,
310                [
311                    (CONTENT_RANGE, format!("lines */{line_total}")),
312                    (
313                        HeaderName::from_static("x-content-range-bytes"),
314                        format!("*/{byte_total}"),
315                    ),
316                ],
317            )
318                .into_response();
319        }
320
321        // 416 Range Not Satisfiable: RFC 7233 §4.4 requires
322        // `Content-Range: bytes */N` and an empty body.
323        if let Some(complete_length) = self.error.range_not_satisfiable_length() {
324            let content_range = format!("bytes */{complete_length}");
325            return (
326                StatusCode::RANGE_NOT_SATISFIABLE,
327                [(CONTENT_RANGE, content_range)],
328            )
329                .into_response();
330        }
331
332        // 304 Not Modified: RFC 7232 §4.1 forbids a message body.
333        if self.error.is_not_modified() {
334            return StatusCode::NOT_MODIFIED.into_response();
335        }
336
337        match &self.error {
338            ApiError::Unauthorized => {
339                let body = ErrorBody {
340                    error: "unauthorized",
341                    message: "provide a valid Bearer token in the Authorization header".to_string(),
342                    request_id: self.request_id,
343                };
344                return (StatusCode::UNAUTHORIZED, Json(body)).into_response();
345            }
346            ApiError::IndexerBackpressureUpsert => {
347                return Self::retry_later(
348                    self.request_id,
349                    "object stored; indexer queue full — retry to re-enqueue".to_string(),
350                );
351            }
352            ApiError::IndexerBackpressureTombstone => {
353                return Self::retry_later(
354                    self.request_id,
355                    "deleted from storage; retry to clear from search index".to_string(),
356                );
357            }
358            ApiError::EventPublishFailed { after } => {
359                let message = match after {
360                    notedthat_write::WriteEffect::Stored => {
361                        "object stored; change event not published — retry to publish"
362                    }
363                    notedthat_write::WriteEffect::Deleted => {
364                        "deleted from storage; change event not published — retry to publish"
365                    }
366                };
367                return Self::retry_later(self.request_id, message.to_string());
368            }
369            ApiError::EventsUnavailable { message } => {
370                return Self::retry_later(
371                    self.request_id,
372                    format!("event backend unavailable: {message}"),
373                );
374            }
375            ApiError::ReplaceAmbiguous { count } => {
376                let body = ReplaceAmbiguousBody {
377                    error: "ambiguous_match",
378                    message: self.error.to_string(),
379                    request_id: self.request_id,
380                    match_count: *count,
381                };
382                return (StatusCode::UNPROCESSABLE_ENTITY, Json(body)).into_response();
383            }
384            _ => {}
385        }
386
387        // All other variants return a JSON error body.
388        let (status, code) = self.error.status_and_code();
389        let message = self.error.to_string();
390        let body = ErrorBody {
391            error: code,
392            message,
393            request_id: self.request_id,
394        };
395        (status, Json(body)).into_response()
396    }
397}
398
399impl IntoResponse for ApiError {
400    fn into_response(self) -> Response {
401        ApiErrorResponse {
402            error: self,
403            request_id: "unknown".to_string(),
404        }
405        .into_response()
406    }
407}
408
409#[cfg(test)]
410mod tests {
411    use super::*;
412    use axum::body::{Body, to_bytes};
413    use axum::http::Request;
414    use bytes::Bytes;
415    use notedthat_core::{ConditionalHeaders, KbSlug, ObjectPath, Storage};
416    use notedthat_indexer::IndexEvent;
417    use notedthat_write::WriteError;
418    use std::collections::BTreeMap;
419    use std::sync::Arc;
420    use tower::util::ServiceExt;
421
422    const KB: &str = "notes";
423    const TOKEN: &str = "test-token-abc";
424
425    fn router() -> axum::Router {
426        router_with_max_patchable_size(16 * 1024 * 1024)
427    }
428
429    fn router_with_max_patchable_size(max_patchable_size: u64) -> axum::Router {
430        let kb = KbSlug::try_new(KB).unwrap();
431        let mut kbs = BTreeMap::new();
432        kbs.insert(KB.to_string(), kb);
433        let (indexer_tx, mut rx) = tokio::sync::mpsc::channel(16);
434        tokio::spawn(async move { while rx.recv().await.is_some() {} });
435
436        crate::router::build_router(crate::state::AppState {
437            storage: Arc::new(crate::testing::InMemoryStorage::default()),
438            access_policies: Arc::new(notedthat_core::signed_in_policies(&kbs)),
439            declared_kbs: Arc::new(kbs),
440            authenticator: Arc::new(notedthat_core::Authenticator::new(TOKEN)),
441            max_body_size: 16 * 1024 * 1024,
442            max_patchable_size,
443            indexer_tx,
444            searcher: Arc::new(crate::testing::NoopSearcher),
445            events: None,
446        })
447    }
448
449    fn router_with_storage_and_indexer(
450        storage: Arc<dyn Storage>,
451        kb: KbSlug,
452        max_patchable_size: u64,
453        indexer_tx: tokio::sync::mpsc::Sender<IndexEvent>,
454    ) -> axum::Router {
455        let mut kbs = BTreeMap::new();
456        kbs.insert(KB.to_string(), kb);
457        crate::router::build_router(crate::state::AppState {
458            storage,
459            access_policies: Arc::new(notedthat_core::signed_in_policies(&kbs)),
460            declared_kbs: Arc::new(kbs),
461            authenticator: Arc::new(notedthat_core::Authenticator::new(TOKEN)),
462            max_body_size: 16 * 1024 * 1024,
463            max_patchable_size,
464            indexer_tx,
465            searcher: Arc::new(crate::testing::NoopSearcher),
466            events: None,
467        })
468    }
469
470    async fn put_object(router: axum::Router, path: &str, body: &'static [u8]) -> String {
471        let response = router
472            .oneshot(
473                Request::builder()
474                    .method("PUT")
475                    .uri(format!("/api/v1/knowledgebases/{KB}/{path}"))
476                    .header("authorization", format!("Bearer {TOKEN}"))
477                    .header(axum::http::header::CONTENT_TYPE, "text/markdown")
478                    .body(Body::from(Bytes::from_static(body)))
479                    .unwrap(),
480            )
481            .await
482            .unwrap();
483
484        assert_eq!(response.status(), StatusCode::CREATED);
485        response
486            .headers()
487            .get(axum::http::header::ETAG)
488            .unwrap()
489            .to_str()
490            .unwrap()
491            .to_string()
492    }
493
494    async fn get_object(router: axum::Router, path: &str) -> Bytes {
495        let response = router
496            .oneshot(
497                Request::builder()
498                    .method("GET")
499                    .uri(format!("/api/v1/knowledgebases/{KB}/{path}"))
500                    .header("authorization", format!("Bearer {TOKEN}"))
501                    .body(Body::empty())
502                    .unwrap(),
503            )
504            .await
505            .unwrap();
506
507        assert_eq!(response.status(), StatusCode::OK);
508        to_bytes(response.into_body(), usize::MAX).await.unwrap()
509    }
510
511    async fn post_replace(
512        router: axum::Router,
513        path: &str,
514        if_match: &str,
515        body: &'static [u8],
516    ) -> Response {
517        router
518            .oneshot(
519                Request::builder()
520                    .method("POST")
521                    .uri(format!("/api/v1/knowledgebases/{KB}/replace/{path}"))
522                    .header("authorization", format!("Bearer {TOKEN}"))
523                    .header(axum::http::header::CONTENT_TYPE, "application/json")
524                    .header(axum::http::header::IF_MATCH, if_match)
525                    .body(Body::from(Bytes::from_static(body)))
526                    .unwrap(),
527            )
528            .await
529            .unwrap()
530    }
531
532    async fn object_with_etag(
533        storage: &crate::testing::InMemoryStorage,
534        kb: &KbSlug,
535        path: &str,
536        body: &'static [u8],
537    ) -> String {
538        storage
539            .put_object(
540                kb,
541                &ObjectPath::try_from_str(path).unwrap(),
542                Bytes::from_static(body),
543                Some("text/markdown"),
544                ConditionalHeaders::default(),
545            )
546            .await
547            .unwrap()
548            .etag
549            .unwrap()
550    }
551
552    async fn assert_invalid_request_response(response: Response) {
553        assert_eq!(response.status(), StatusCode::BAD_REQUEST);
554        let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
555        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
556        assert_eq!(json["error"], "invalid_request");
557    }
558
559    #[tokio::test]
560    async fn test_unauthorized_status_and_body() {
561        let resp = ApiErrorResponse::unauthorized("req-123".to_string()).into_response();
562        assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
563        let body = to_bytes(resp.into_body(), usize::MAX).await.unwrap();
564        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
565        assert_eq!(json["error"], "unauthorized");
566        assert_eq!(json["request_id"], "req-123");
567    }
568
569    #[tokio::test]
570    async fn test_not_found_status() {
571        let err = ApiError::Core(CoreError::NotFound {
572            resource: "foo".into(),
573        });
574        let resp = ApiErrorResponse {
575            error: err,
576            request_id: "rid".into(),
577        }
578        .into_response();
579        assert_eq!(resp.status(), StatusCode::NOT_FOUND);
580    }
581
582    #[tokio::test]
583    async fn test_payload_too_large_status() {
584        let err = ApiError::Core(CoreError::PayloadTooLarge {
585            size: 20_000_000,
586            limit: 16_777_216,
587        });
588        let resp = ApiErrorResponse {
589            error: err,
590            request_id: "rid".into(),
591        }
592        .into_response();
593        assert_eq!(resp.status(), StatusCode::PAYLOAD_TOO_LARGE);
594    }
595
596    #[tokio::test]
597    async fn test_request_id_in_body() {
598        let err = ApiError::Core(CoreError::InvalidInput {
599            message: "bad".into(),
600        });
601        let resp = ApiErrorResponse {
602            error: err,
603            request_id: "my-req-id".into(),
604        }
605        .into_response();
606        let body = to_bytes(resp.into_body(), usize::MAX).await.unwrap();
607        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
608        assert_eq!(json["request_id"], "my-req-id");
609    }
610
611    #[tokio::test]
612    async fn test_indexer_backpressure_upsert_503_body_and_retry_after() {
613        let resp = ApiErrorResponse {
614            error: ApiError::IndexerBackpressureUpsert,
615            request_id: "rid".to_string(),
616        }
617        .into_response();
618
619        assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
620        assert_eq!(resp.headers().get("retry-after").unwrap(), "5");
621        let body = to_bytes(resp.into_body(), usize::MAX).await.unwrap();
622        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
623        assert_eq!(json["error"], "backend_unavailable");
624        assert_eq!(
625            json["message"],
626            "object stored; indexer queue full — retry to re-enqueue"
627        );
628        assert_eq!(json["request_id"], "rid");
629    }
630
631    #[tokio::test]
632    async fn test_indexer_backpressure_tombstone_503_body_and_retry_after() {
633        let resp = ApiErrorResponse {
634            error: ApiError::IndexerBackpressureTombstone,
635            request_id: "rid".to_string(),
636        }
637        .into_response();
638
639        assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
640        assert_eq!(resp.headers().get("retry-after").unwrap(), "5");
641        let body = to_bytes(resp.into_body(), usize::MAX).await.unwrap();
642        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
643        assert_eq!(json["error"], "backend_unavailable");
644        assert_eq!(
645            json["message"],
646            "deleted from storage; retry to clear from search index"
647        );
648        assert_eq!(json["request_id"], "rid");
649    }
650
651    #[test]
652    fn test_from_write_error_indexer_backpressure() {
653        assert!(matches!(
654            ApiError::from(WriteError::IndexerBackpressureUpsert),
655            ApiError::IndexerBackpressureUpsert
656        ));
657        assert!(matches!(
658            ApiError::from(WriteError::IndexerBackpressureTombstone),
659            ApiError::IndexerBackpressureTombstone
660        ));
661    }
662
663    #[test]
664    fn test_from_write_error_patch_too_large() {
665        let api_err = ApiError::from(WriteError::PatchTooLarge {
666            size: 200 * 1024 * 1024,
667            limit: 100 * 1024 * 1024,
668        });
669
670        let (status, code) = api_err.status_and_code();
671        assert_eq!(status.as_u16(), 413);
672        assert_eq!(code, "payload_too_large");
673    }
674
675    #[test]
676    fn test_from_write_error_patch_line_out_of_range() {
677        let api_err = ApiError::from(WriteError::PatchLineOutOfRange {
678            first: 999,
679            last: 1000,
680            total_lines: 20,
681            total_bytes: 100,
682        });
683
684        let (status, code) = api_err.status_and_code();
685        assert_eq!(status.as_u16(), 416);
686        assert_eq!(code, "range_not_satisfiable");
687    }
688
689    #[test]
690    fn test_from_write_error_patch_invalid_range() {
691        let api_err = ApiError::from(WriteError::PatchInvalidRange {
692            message: "test".into(),
693        });
694
695        let (status, code) = api_err.status_and_code();
696        assert_eq!(status.as_u16(), 400);
697        assert_eq!(code, "invalid_request");
698    }
699
700    #[test]
701    fn test_from_write_error_replace_no_match() {
702        let api_err = ApiError::from(WriteError::ReplaceNoMatch);
703
704        let (status, code) = api_err.status_and_code();
705        assert_eq!(status.as_u16(), 422);
706        assert_eq!(code, "no_match");
707    }
708
709    #[test]
710    fn test_from_write_error_replace_ambiguous() {
711        let api_err = ApiError::from(WriteError::ReplaceAmbiguous { count: 3 });
712
713        let (status, code) = api_err.status_and_code();
714        assert_eq!(status.as_u16(), 422);
715        assert_eq!(code, "ambiguous_match");
716    }
717
718    #[tokio::test]
719    async fn test_ambiguous_match_body_includes_match_count() {
720        let resp = ApiErrorResponse {
721            error: ApiError::ReplaceAmbiguous { count: 3 },
722            request_id: "req-1".into(),
723        }
724        .into_response();
725
726        assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY);
727        let body = to_bytes(resp.into_body(), usize::MAX).await.unwrap();
728        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
729        assert_eq!(json["error"], "ambiguous_match");
730        assert_eq!(json["match_count"], 3);
731        assert_eq!(json["request_id"], "req-1");
732    }
733
734    #[tokio::test]
735    async fn test_no_match_body_omits_match_count() {
736        let resp = ApiErrorResponse {
737            error: ApiError::ReplaceNoMatch,
738            request_id: "req-1".into(),
739        }
740        .into_response();
741
742        assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY);
743        let body = to_bytes(resp.into_body(), usize::MAX).await.unwrap();
744        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
745        assert_eq!(json["error"], "no_match");
746        assert!(json.get("match_count").is_none());
747    }
748
749    #[tokio::test]
750    async fn replace_missing_if_match_returns_400_invalid_request() {
751        let response = router()
752            .oneshot(
753                Request::builder()
754                    .method("POST")
755                    .uri(format!("/api/v1/knowledgebases/{KB}/replace/hello.md"))
756                    .header("authorization", format!("Bearer {TOKEN}"))
757                    .header(axum::http::header::CONTENT_TYPE, "application/json")
758                    .body(Body::from(Bytes::from_static(
759                        br#"{"old_string":"x","new_string":"y"}"#,
760                    )))
761                    .unwrap(),
762            )
763            .await
764            .unwrap();
765
766        assert_invalid_request_response(response).await;
767    }
768
769    #[tokio::test]
770    async fn replace_if_match_star_returns_400() {
771        let response = router()
772            .oneshot(
773                Request::builder()
774                    .method("POST")
775                    .uri(format!("/api/v1/knowledgebases/{KB}/replace/hello.md"))
776                    .header("authorization", format!("Bearer {TOKEN}"))
777                    .header(axum::http::header::CONTENT_TYPE, "application/json")
778                    .header(axum::http::header::IF_MATCH, "*")
779                    .body(Body::from(Bytes::from_static(
780                        br#"{"old_string":"x","new_string":"y"}"#,
781                    )))
782                    .unwrap(),
783            )
784            .await
785            .unwrap();
786
787        assert_invalid_request_response(response).await;
788    }
789
790    #[tokio::test]
791    async fn replace_multi_value_if_match_returns_400() {
792        let response = router()
793            .oneshot(
794                Request::builder()
795                    .method("POST")
796                    .uri(format!("/api/v1/knowledgebases/{KB}/replace/hello.md"))
797                    .header("authorization", format!("Bearer {TOKEN}"))
798                    .header(axum::http::header::CONTENT_TYPE, "application/json")
799                    .header(axum::http::header::IF_MATCH, "\"a\",\"b\"")
800                    .body(Body::from(Bytes::from_static(
801                        br#"{"old_string":"x","new_string":"y"}"#,
802                    )))
803                    .unwrap(),
804            )
805            .await
806            .unwrap();
807
808        assert_invalid_request_response(response).await;
809    }
810
811    #[tokio::test]
812    async fn replace_malformed_json_body_returns_400() {
813        let response = router()
814            .oneshot(
815                Request::builder()
816                    .method("POST")
817                    .uri(format!("/api/v1/knowledgebases/{KB}/replace/hello.md"))
818                    .header("authorization", format!("Bearer {TOKEN}"))
819                    .header(axum::http::header::CONTENT_TYPE, "application/json")
820                    .header(axum::http::header::IF_MATCH, "\"valid-etag\"")
821                    .body(Body::from(Bytes::from_static(b"{invalid json")))
822                    .unwrap(),
823            )
824            .await
825            .unwrap();
826
827        assert_invalid_request_response(response).await;
828    }
829
830    #[tokio::test]
831    async fn replace_missing_old_string_field_returns_400() {
832        let response = router()
833            .oneshot(
834                Request::builder()
835                    .method("POST")
836                    .uri(format!("/api/v1/knowledgebases/{KB}/replace/hello.md"))
837                    .header("authorization", format!("Bearer {TOKEN}"))
838                    .header(axum::http::header::CONTENT_TYPE, "application/json")
839                    .header(axum::http::header::IF_MATCH, "\"valid-etag\"")
840                    .body(Body::from(Bytes::from_static(br#"{"new_string":"y"}"#)))
841                    .unwrap(),
842            )
843            .await
844            .unwrap();
845
846        assert_invalid_request_response(response).await;
847    }
848
849    #[tokio::test]
850    async fn replace_empty_old_string_returns_400() {
851        let response = router()
852            .oneshot(
853                Request::builder()
854                    .method("POST")
855                    .uri(format!("/api/v1/knowledgebases/{KB}/replace/hello.md"))
856                    .header("authorization", format!("Bearer {TOKEN}"))
857                    .header(axum::http::header::CONTENT_TYPE, "application/json")
858                    .header(axum::http::header::IF_MATCH, "\"valid-etag\"")
859                    .body(Body::from(Bytes::from_static(
860                        br#"{"old_string":"","new_string":"y"}"#,
861                    )))
862                    .unwrap(),
863            )
864            .await
865            .unwrap();
866
867        assert_invalid_request_response(response).await;
868    }
869
870    #[tokio::test]
871    async fn replace_single_match_happy_returns_200_with_etag_and_match_count() {
872        let router = router();
873        let etag = put_object(router.clone(), "hello.md", b"hello world").await;
874
875        let response = post_replace(
876            router.clone(),
877            "hello.md",
878            &etag,
879            br#"{"old_string":"world","new_string":"planet"}"#,
880        )
881        .await;
882
883        assert_eq!(response.status(), StatusCode::OK);
884        assert!(response.headers().get(axum::http::header::ETAG).is_some());
885        assert_eq!(
886            response.headers().get("content-location").unwrap(),
887            "/api/v1/knowledgebases/notes/hello.md"
888        );
889        let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
890        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
891        assert!(json["etag"].as_str().is_some());
892        assert_eq!(json["match_count"], 1);
893        assert_eq!(json["total_bytes"], 12);
894        assert_eq!(&get_object(router, "hello.md").await[..], b"hello planet");
895    }
896
897    #[tokio::test]
898    async fn replace_no_match_returns_422_no_match() {
899        let router = router();
900        let etag = put_object(router.clone(), "hello.md", b"hello world").await;
901
902        let response = post_replace(
903            router.clone(),
904            "hello.md",
905            &etag,
906            br#"{"old_string":"nonexistent","new_string":"x"}"#,
907        )
908        .await;
909
910        assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY);
911        let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
912        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
913        assert_eq!(json["error"], "no_match");
914        assert_eq!(&get_object(router, "hello.md").await[..], b"hello world");
915    }
916
917    #[tokio::test]
918    async fn replace_ambiguous_returns_422_with_match_count() {
919        let router = router();
920        let etag = put_object(router.clone(), "hello.md", b"a b a").await;
921
922        let response = post_replace(
923            router.clone(),
924            "hello.md",
925            &etag,
926            br#"{"old_string":"a","new_string":"Z"}"#,
927        )
928        .await;
929
930        assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY);
931        let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
932        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
933        assert_eq!(json["error"], "ambiguous_match");
934        assert_eq!(json["match_count"], 2);
935        assert_eq!(&get_object(router, "hello.md").await[..], b"a b a");
936    }
937
938    #[tokio::test]
939    async fn replace_all_true_multiple_matches_returns_200_with_count_2() {
940        let router = router();
941        let etag = put_object(router.clone(), "hello.md", b"a b a").await;
942
943        let response = post_replace(
944            router.clone(),
945            "hello.md",
946            &etag,
947            br#"{"old_string":"a","new_string":"Z","replace_all":true}"#,
948        )
949        .await;
950
951        assert_eq!(response.status(), StatusCode::OK);
952        let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
953        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
954        assert_eq!(json["match_count"], 2);
955        assert_eq!(json["total_bytes"], 5);
956        assert_eq!(&get_object(router, "hello.md").await[..], b"Z b Z");
957    }
958
959    #[tokio::test]
960    async fn replace_stale_etag_returns_412() {
961        let router = router();
962        put_object(router.clone(), "hello.md", b"hello world").await;
963
964        let response = post_replace(
965            router,
966            "hello.md",
967            "\"stale\"",
968            br#"{"old_string":"world","new_string":"planet"}"#,
969        )
970        .await;
971
972        assert_eq!(response.status(), StatusCode::PRECONDITION_FAILED);
973    }
974
975    #[tokio::test]
976    async fn replace_post_splice_size_over_cap_returns_413() {
977        let router = router_with_max_patchable_size(20);
978        let etag = put_object(router.clone(), "hello.md", b"1234567890").await;
979
980        let response = post_replace(
981            router,
982            "hello.md",
983            &etag,
984            br#"{"old_string":"0","new_string":"abcdefghijklmnopqrstuvwxyz"}"#,
985        )
986        .await;
987
988        assert_eq!(response.status(), StatusCode::PAYLOAD_TOO_LARGE);
989        let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
990        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
991        assert_eq!(json["error"], "payload_too_large");
992    }
993
994    #[tokio::test]
995    async fn replace_indexer_backpressure_returns_503_with_retry_after() {
996        let kb = KbSlug::try_new(KB).unwrap();
997        let storage = crate::testing::InMemoryStorage::default();
998        let etag = object_with_etag(&storage, &kb, "hello.md", b"hello world").await;
999        let (indexer_tx, _rx) = tokio::sync::mpsc::channel(1);
1000        indexer_tx
1001            .try_send(IndexEvent::Upsert {
1002                kb: kb.clone(),
1003                object_key: ObjectPath::try_from_str("queued.md").unwrap(),
1004                etag: "queued".to_string(),
1005                mtime: 0,
1006            })
1007            .unwrap();
1008        let router =
1009            router_with_storage_and_indexer(Arc::new(storage), kb, 16 * 1024 * 1024, indexer_tx);
1010
1011        let response = post_replace(
1012            router,
1013            "hello.md",
1014            &etag,
1015            br#"{"old_string":"world","new_string":"planet"}"#,
1016        )
1017        .await;
1018
1019        assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
1020        assert_eq!(response.headers().get("retry-after").unwrap(), "5");
1021        let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
1022        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
1023        assert_eq!(json["error"], "backend_unavailable");
1024    }
1025
1026    #[tokio::test]
1027    async fn test_precondition_failed_body_shape_unchanged() {
1028        let resp = ApiErrorResponse {
1029            error: ApiError::PreconditionFailed,
1030            request_id: "req-1".into(),
1031        }
1032        .into_response();
1033
1034        assert_eq!(resp.status(), StatusCode::PRECONDITION_FAILED);
1035        let body = to_bytes(resp.into_body(), usize::MAX).await.unwrap();
1036        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
1037        let object = json.as_object().unwrap();
1038        assert_eq!(object.len(), 3);
1039        assert!(object.contains_key("error"));
1040        assert!(object.contains_key("message"));
1041        assert!(object.contains_key("request_id"));
1042    }
1043
1044    // ─── New variant tests ────────────────────────────────────────────────────
1045
1046    #[tokio::test]
1047    async fn test_precondition_failed_412() {
1048        let resp = ApiError::PreconditionFailed.into_response();
1049        assert_eq!(resp.status(), StatusCode::PRECONDITION_FAILED);
1050    }
1051
1052    #[tokio::test]
1053    async fn test_not_modified_304_empty_body() {
1054        let resp = ApiError::NotModified.into_response();
1055        assert_eq!(resp.status(), StatusCode::NOT_MODIFIED);
1056        let body = to_bytes(resp.into_body(), usize::MAX).await.unwrap();
1057        assert!(body.is_empty(), "304 must have an empty body");
1058    }
1059
1060    #[tokio::test]
1061    async fn test_malformed_range_400() {
1062        let resp = ApiError::MalformedRange("bytes=abc".to_string()).into_response();
1063        assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
1064    }
1065
1066    /// RFC 7233 §4.4: a 416 response MUST include `Content-Range: bytes */N`.
1067    #[tokio::test]
1068    async fn test_range_not_satisfiable_416_content_range_header() {
1069        let resp = ApiError::RangeNotSatisfiable {
1070            complete_length: 100,
1071        }
1072        .into_response();
1073        assert_eq!(resp.status(), StatusCode::RANGE_NOT_SATISFIABLE);
1074        let cr = resp
1075            .headers()
1076            .get("content-range")
1077            .expect("content-range header must be present on 416");
1078        assert_eq!(cr.to_str().unwrap(), "bytes */100");
1079        let body = to_bytes(resp.into_body(), usize::MAX).await.unwrap();
1080        assert!(body.is_empty(), "416 body must be empty per RFC 7233 §4.4");
1081    }
1082
1083    /// Same 416 check via the `ApiError::Storage(...)` wrapper (e.g. from router.rs
1084    /// call sites that use explicit wrapping instead of `Into::into`).
1085    #[tokio::test]
1086    async fn test_storage_range_not_satisfiable_416_content_range_header() {
1087        let resp = ApiError::Storage(StorageError::RangeNotSatisfiable {
1088            complete_length: 42,
1089        })
1090        .into_response();
1091        assert_eq!(resp.status(), StatusCode::RANGE_NOT_SATISFIABLE);
1092        let cr = resp
1093            .headers()
1094            .get("content-range")
1095            .expect("content-range header must be present on 416");
1096        assert_eq!(cr.to_str().unwrap(), "bytes */42");
1097    }
1098
1099    /// 304 from a wrapped `StorageError::NotModified` (explicit wrapping in router.rs).
1100    #[tokio::test]
1101    async fn test_storage_not_modified_304_empty_body() {
1102        let resp = ApiError::Storage(StorageError::NotModified).into_response();
1103        assert_eq!(resp.status(), StatusCode::NOT_MODIFIED);
1104        let body = to_bytes(resp.into_body(), usize::MAX).await.unwrap();
1105        assert!(body.is_empty(), "304 must have an empty body");
1106    }
1107
1108    /// `From<StorageError>` promotes `NotModified` to `ApiError::NotModified`.
1109    #[test]
1110    fn test_from_storage_error_not_modified() {
1111        let api_err = ApiError::from(StorageError::NotModified);
1112        assert!(matches!(api_err, ApiError::NotModified));
1113    }
1114
1115    /// `From<StorageError>` promotes `PreconditionFailed` to `ApiError::PreconditionFailed`.
1116    #[test]
1117    fn test_from_storage_error_precondition_failed() {
1118        let api_err = ApiError::from(StorageError::PreconditionFailed);
1119        assert!(matches!(api_err, ApiError::PreconditionFailed));
1120    }
1121
1122    /// `From<StorageError>` promotes `RangeNotSatisfiable` with the correct length.
1123    #[test]
1124    fn test_from_storage_error_range_not_satisfiable() {
1125        let api_err = ApiError::from(StorageError::RangeNotSatisfiable {
1126            complete_length: 999,
1127        });
1128        assert!(
1129            matches!(
1130                api_err,
1131                ApiError::RangeNotSatisfiable {
1132                    complete_length: 999
1133                }
1134            ),
1135            "expected RangeNotSatisfiable with complete_length=999, got {api_err:?}"
1136        );
1137    }
1138
1139    /// Other `StorageError` variants must still be wrapped in `ApiError::Storage`.
1140    #[test]
1141    fn test_from_storage_error_other_wrapped() {
1142        let api_err = ApiError::from(StorageError::NotFound {
1143            key: "foo".to_string(),
1144        });
1145        assert!(matches!(api_err, ApiError::Storage(_)));
1146    }
1147
1148    mod line_range_error {
1149        use super::*;
1150        use axum::body::Body;
1151        use axum::http::Request;
1152        use bytes::Bytes;
1153        use notedthat_core::KbSlug;
1154        use std::collections::BTreeMap;
1155        use std::sync::Arc;
1156        use tower::util::ServiceExt;
1157
1158        const KB: &str = "notes";
1159        const TOKEN: &str = "test-token-abc";
1160
1161        fn twenty_line_markdown() -> String {
1162            let mut body = String::new();
1163            for line in 1..=20 {
1164                std::fmt::Write::write_fmt(&mut body, format_args!("line {line:02}\n")).unwrap();
1165            }
1166            body
1167        }
1168
1169        fn router() -> axum::Router {
1170            let kb = KbSlug::try_new(KB).unwrap();
1171            let mut kbs = BTreeMap::new();
1172            kbs.insert(KB.to_string(), kb);
1173            let (indexer_tx, mut rx) = tokio::sync::mpsc::channel(16);
1174            tokio::spawn(async move { while rx.recv().await.is_some() {} });
1175
1176            crate::router::build_router(crate::state::AppState {
1177                storage: Arc::new(crate::testing::InMemoryStorage::default()),
1178                access_policies: Arc::new(notedthat_core::signed_in_policies(&kbs)),
1179                declared_kbs: Arc::new(kbs),
1180                authenticator: Arc::new(notedthat_core::Authenticator::new(TOKEN)),
1181                max_body_size: 16 * 1024 * 1024,
1182                max_patchable_size: 16 * 1024 * 1024,
1183                indexer_tx,
1184                searcher: Arc::new(crate::testing::NoopSearcher),
1185                events: None,
1186            })
1187        }
1188
1189        async fn put_ranges_md(router: axum::Router) {
1190            let response = router
1191                .oneshot(
1192                    Request::builder()
1193                        .method("PUT")
1194                        .uri(format!("/api/v1/knowledgebases/{KB}/ranges.md"))
1195                        .header("authorization", format!("Bearer {TOKEN}"))
1196                        .header(axum::http::header::CONTENT_TYPE, "text/markdown")
1197                        .body(Body::from(Bytes::from(twenty_line_markdown())))
1198                        .unwrap(),
1199                )
1200                .await
1201                .unwrap();
1202
1203            assert_eq!(response.status(), StatusCode::CREATED);
1204        }
1205
1206        #[tokio::test]
1207        async fn malformed_line_range_returns_json_400() {
1208            let response = ApiError::MalformedRange("lines=abc".into()).into_response();
1209
1210            assert_eq!(response.status(), StatusCode::BAD_REQUEST);
1211            let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
1212            let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
1213            assert_eq!(json["error"], "malformed_range");
1214        }
1215
1216        #[tokio::test]
1217        async fn line_range_not_satisfiable_returns_dual_headers_and_empty_body() {
1218            let response = ApiError::LineRangeNotSatisfiable {
1219                line_total: 20,
1220                byte_total: 100,
1221            }
1222            .into_response();
1223
1224            assert_eq!(response.status(), StatusCode::RANGE_NOT_SATISFIABLE);
1225            assert_eq!(
1226                response.headers().get("content-range").unwrap(),
1227                "lines */20"
1228            );
1229            assert_eq!(
1230                response.headers().get("x-content-range-bytes").unwrap(),
1231                "*/100"
1232            );
1233            let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
1234            assert!(body.is_empty());
1235        }
1236
1237        #[tokio::test]
1238        async fn out_of_range_line_get_returns_dual_headers_and_empty_body() {
1239            let router = router();
1240            put_ranges_md(router.clone()).await;
1241
1242            let response = router
1243                .oneshot(
1244                    Request::builder()
1245                        .method("GET")
1246                        .uri(format!("/api/v1/knowledgebases/{KB}/ranges.md"))
1247                        .header("authorization", format!("Bearer {TOKEN}"))
1248                        .header(axum::http::header::RANGE, "lines=100-200")
1249                        .body(Body::empty())
1250                        .unwrap(),
1251                )
1252                .await
1253                .unwrap();
1254
1255            assert_eq!(response.status(), StatusCode::RANGE_NOT_SATISFIABLE);
1256            assert_eq!(
1257                response.headers().get("content-range").unwrap(),
1258                "lines */20"
1259            );
1260            assert_eq!(
1261                response.headers().get("x-content-range-bytes").unwrap(),
1262                "*/160"
1263            );
1264            let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
1265            assert!(body.is_empty());
1266        }
1267
1268        #[tokio::test]
1269        async fn byte_range_not_satisfiable_omits_line_byte_header() {
1270            let response = ApiError::RangeNotSatisfiable {
1271                complete_length: 100,
1272            }
1273            .into_response();
1274
1275            assert_eq!(response.status(), StatusCode::RANGE_NOT_SATISFIABLE);
1276            assert_eq!(
1277                response.headers().get("content-range").unwrap(),
1278                "bytes */100"
1279            );
1280            assert!(response.headers().get("x-content-range-bytes").is_none());
1281        }
1282
1283        #[test]
1284        fn patch_line_out_of_range_maps_to_line_range_not_satisfiable() {
1285            let error = ApiError::from(WriteError::PatchLineOutOfRange {
1286                first: 100,
1287                last: 200,
1288                total_lines: 20,
1289                total_bytes: 100,
1290            });
1291
1292            assert!(matches!(
1293                error,
1294                ApiError::LineRangeNotSatisfiable {
1295                    line_total: 20,
1296                    byte_total: 100
1297                }
1298            ));
1299        }
1300    }
1301}