summaryrefslogtreecommitdiff
path: root/web-client-with-cache.org
blob: eed7a8dfbb2632c2730bab815ea2a0c155d2bf75 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
#+title: Web Client with Cache
#+subtitle: for version {{{version}}}, {{{updated}}}
#+author: Vivien Kraus
#+email: vivien@planete-kraus.eu
#+options: ':t toc:t author:t email:t
#+language: en

#+macro: version @@texinfo:@value{VERSION}@@
#+macro: updated @@texinfo:@value{UPDATED}@@
#+texinfo_filename: web-client-with-cache.info
#+texinfo_header: @syncodeindex pg cp
#+texinfo_header: @syncodeindex pg fn
#+texinfo_header: @include version.texi
#+texinfo_dir_category: The Algorithmic Language Scheme
#+texinfo_dir_title: Web Client with Cache: (web-client-with-cache)
#+texinfo_dir_desc: Using the cache
#+texinfo_printed_title: Web Client with Cache

This manual is for the Guile Web Client with Cache (version
{{{version}}}, {{{updated}}}). The source code can be downloaded at
[[https://labo.planete-kraus.eu/web-client-with-cache.git]].

* Copying
  :PROPERTIES:
  :COPYING:  t
  :END:
This manual is for the Guile Web Client with Cache.

Copyright \copy 2021 Vivien Kraus.

#+begin_quote
Permission is granted to copy, distribute and/or modify this document
under the terms of the GNU Free Documentation License, Version 1.3 or
any later version published by the Free Software Foundation; with no
Invariant Sections, with no Front-Cover Texts, and with no Back-Cover
Texts.  A copy of the license is included in the section entitled "GNU
Free Documentation License".

The program present in this document is free software: you can
redistribute it and/or modify it under the terms of the GNU General
Public License as published by the Free Software Foundation, either
version 3 of the License, or (at your option) any later version.

This program is distributed in the hope that it will be useful, but
WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
General Public License for more details.

You should have received a copy of the GNU General Public License
along with this program.  If not, see [[https://www.gnu.org/licenses/]].
#+end_quote

* Installation
The preferred method to install the web client is through Guix. Make
sure that your channels file (under =$HOME/.config/guix/channels.scm=)
looks like this:

#+begin_src scheme :eval no
  (cons (channel
         (name 'web-client-with-cache)
         (url "https://labo.planete-kraus.eu/web-client-with-cache.git"))
        %default-channels)
#+end_src

Then, run guix pull:

#+begin_src shell :eval no
  guix pull
#+end_src

You will then be able to install the package:

#+begin_src shell :eval no
  guix install guile-web-client-with-cache
#+end_src

If you don't have guix installed, you can still download the latest
distribution tarball and install it:

#+begin_src shell :eval no
  wget 'https://web-client-with-cache.planete-kraus.eu/source.tar.gz'
  tar xf source.tar.gz
  cd web-client-with-cache-*
  ./configure
  make
  make install
#+end_src
* Using the cache
#+cindex: using the cache

The API is defined in the =(web client with-cache)= module. It
consists of one function:

#+begin_src emacs-lisp :noweb yes :exports results :results drawer
  (format "
  ,#+begin_export texinfo
  @defun with-cache [#:http-get] [#:current-time] [#:box]
  <<with-cache-docstring>>
  @end defun
  ,#+end_export")
#+end_src

* Covoiturage
The goal here is to wrap an expensive long-running pure function so
that calling it multiple times with the same arguments until it has
not returned reuses the return value. The name is not pretty, if you
find a better one please tell me.

#+name: covoiturage-docstring
#+caption: Docstring of the covoiturage function
#+begin_src texinfo
  Wrap @var{f} so that simultaneous calls use the same return value. By
  simultaneous, we mean parallel calls with the same value for the first
  argument (compared with @code{equal?}) during the long-running
  evaluation of @var{f}.

  @var{f} takes at least one argument, the key. @var{f} may throw or
  return. It is executed in a Future (from @code{(ice-9 futures)}, so
  side effects should be synchronized somehow.
#+end_src

Internally, we use an atomic box. The atomic box contains an alist
from key values to promises to futures.  Futures are cool, but once
they are constructed they start running immediately.

However, if the lockless synchronization failed because of a
concurrent update (for instance, inserting a new query), we should not
start f at all before having updated the atomic box. Otherwise, for
http-get, an unwanted connection to the server will still be
attempted.

To work around that, we use a promise to a future, so the
future starts when the promise is forced -- thus, either by our
thread, after we have synchronized the box ourselves; or by another
thread, because it has already been added to the box.

Once the function has returned, we notify all the other waiting
threads and remove everything from the box. Function
[[fn-covoiturage-remove]] does the removal.

#+name: fn-covoiturage-remove
#+caption: Remove a completed entry from a box
#+begin_src scheme :eval no
  (define (remove box key)
    (let* ((old (atomic-box-ref box))
           (new (filter (lambda (record)
                          (not (equal? (car record) key)))
                        old))
           (discarded
            (atomic-box-compare-and-swap! box old new)))
      (unless (eq? discarded old)
        (remove box key))))
#+end_src

The application of function /f/ may either return (multiple values),
or throw. We want to forward both cases to all the callers, so we need
to intercept the throws. Function [[fn-covoiturage-wrap-f]] does that:
call f, and cons either ='ok= or ='error= and the return or throw
values.

#+name: fn-covoiturage-wrap-f
#+caption: Wrap a thunk so that we know whether it threw or returned
#+begin_src scheme :eval no
  (define (wrap-f f)
    (catch #t
      (lambda ()
        (call-with-values f
          (lambda args
            (cons 'ok args))))
      (lambda error
        (cons 'error error))))
#+end_src

Symmetrically, when we want to report the value, we need to apply
either =throw= or =values=. This is the job of [[fn-covoiturage-report]].

#+name: fn-covoiturage-report
#+caption: Report the computed values
#+begin_src scheme :eval no
  (define (report result)
    (case (car result)
      ((ok) (apply values (cdr result)))
      ((error) (apply throw (cdr result)))))
#+end_src

There are now two cases when we query a key: either there is an entry
in the cache, in which case we just remove that, or we need to create
a new future. Function [[fn-covoiturage-create]] will create the job (it
will not start until it has been added to the cache). We note that if
the return value should be cached for longer, the caching should
happen before /f/ has finished executing.

#+name: fn-covoiturage-create
#+caption: Create a new job (don't start it yet)
#+begin_src scheme :eval no
  (define (create box key f args)
    (delay
      (make-future
       (lambda ()
         (let ((ret (wrap-f (lambda () (apply f key args)))))
           (remove box key)
           ret)))))
#+end_src

The query function works atomically: it first reads the cache, use it
if it is present, and otherwise add an entry with a job and start the
job. If it can't do that atomically, it retries. That's function
[[fn-covoiturage-do-query]].

#+name: fn-covoiturage-do-query
#+caption: Do query the cache
#+begin_src scheme :eval no
  (define (do-query box f key args)
    (let ((old (atomic-box-ref box)))
      (if (assoc-ref old key)
          (report (touch (force (assoc-ref old key))))
          (let* ((job (create box key f args))
                 (new (acons key job old)))
            (let ((discarded
                   (atomic-box-compare-and-swap! box old new)))
              (if (eq? discarded old)
                  ;; The box is updated, so the future can be
                  ;; forced and f starts running
                  (report (touch (force job)))
                  ;; Concurrent update, retry.
                  (do-query box f key args)))))))
#+end_src

Finally, we wrap everything in a public function that will manage the
state (the atomic box). The final function is [[fn-covoiturage]].

#+name: fn-covoiturage
#+caption: The final function definition
#+begin_src scheme :eval no :noweb no-export
  (define-public (covoiturage f)
    "\
  <<covoiturage-docstring>>"
    (let ((box (make-atomic-box '())))
      <<fn-covoiturage-remove>>
      <<fn-covoiturage-wrap-f>>
      <<fn-covoiturage-report>>
      <<fn-covoiturage-create>>
      <<fn-covoiturage-do-query>>
      (lambda (key . args)
        (do-query box f key args))))
#+end_src

* Web caching
While we still use an atomic box for this cache, it is more complex
than covoiturage. We need to fix HTTP headers, parse the responses,
manipulate time.

** Filter =#:header= keyword argument

Let's start with the headers. We need a function to parse keyword
arguments, extract the non-ignored headers and the non-header keyword
arguments. The function [[fn-filter-keyword]] extracts the headers, and
[[fn-filter-headers]] removes the unwanted ones.

#+name: fn-filter-keyword
#+caption: Filter a keyword from a keyword list
#+begin_src scheme :eval no
  (define (filter-keyword kw args)
    (define (search args-kept value args)
      (if (null? args)
          (values value (reverse args-kept))
          (let ((next-keyword (car args))
                (next-value (cadr args))
                (rest (cddr args)))
            (if (eq? next-keyword kw)
                (search args-kept (or value next-value) rest)
                (search
                 ;; args-kept is in reverse order!
                 (cons* next-value next-keyword args-kept)
                 value
                 rest)))))
    (search '() #f args))
#+end_src

#+name: fn-filter-headers
#+caption: Remove headers for which we have the responsibility
#+begin_src scheme :eval no
  (define (filter-headers headers)
    (filter (lambda (header)
              (case (car header)
                ((if-none-match if-modified-since) #f)
                (else #t)))
            headers))
#+end_src

** Time management

Now, it's time for time management. We need different auxiliary
functions:
1. Add seconds to a date, which is polymorphic enough to allow SRFI-19
   dates and SRFI-19 times;
2. Compare two dates or times.

*** Adding seconds to a date
Function [[fn-date-add]] adds a number of seconds to a time or a date, and
returns a date.

#+name: fn-date-add
#+caption: Add seconds to a date
#+begin_src scheme :eval no
  (define (date-add date seconds)
    (let* ((duration (make-time time-duration 0 seconds))
           (time (if (time? date)
                     date
                     (date->time-utc date)))
           (result (add-duration time duration)))
      (time-utc->date result)))
#+end_src

*** Compare two dates/times
This is the job of functon [[fn-date-geqp]]. If the arguments are dates,
they are first compared to times.

#+name: fn-date-geqp
#+caption: Compare two dates
#+begin_src scheme :eval no
  (define (date>=? past future)
    (when (date? past)
      (set! past (date->time-utc past)))
    (when (date? future)
      (set! future (date->time-utc future)))
    (time>=? past future))
#+end_src

** Fix the responses
There are different things we need to do to responses:
1. Add a date to every response that does not have one;
2. Estimate an expiration date for a response, based on either the
   Expires header or its Date and Cache-Control information;
3. Merge the headers for two responses.

*** Ensure that a response has a date
If the server did not set a date, we set it from the client
clock. This is the job of [[fn-response-with-date]].

#+name: fn-response-with-date
#+caption: Ensure that the response has a date. Use the client clock if needed.
#+begin_src scheme :eval no
  (define (response-with-date response current-time)
    ;; current-time is only used if response does not have a date.
    (let ((date (response-date response (time-utc->date current-time)))
          (other-headers
           (filter (lambda (header)
                     (not (eq? (car header) 'date)))
                   (response-headers response))))
      (build-response
       #:version (response-version response)
       #:code (response-code response)
       #:reason-phrase (response-reason-phrase response)
       #:headers (acons 'date date other-headers)
       #:port (response-port response))))
#+end_src

*** Estimate the expiration date from the response
The expiration date can be:
- the max-age value of the cache control after the response date, if
  the cache control is present and has a max-age;
- the value of the Expires header;
- =#f= if the others failed.

If the max-age is unreasonably large, the response will overstay. So,
we will cap the max-age with the value of one day.

This is what [[fn-estimate-response-expires]] does.

#+name: fn-estimate-response-expires
#+caption: Estimate the expiration date of a response
#+begin_src scheme :eval no
  (define (estimate-response-expires response)
    (let ((cache-control (response-cache-control response '()))
          (expires (response-expires response))
          (date (response-date response)))
      (let ((max-age (assoc-ref cache-control 'max-age))
            (reasonable-max-age (* 1 24 60 60)))
        (cond (max-age
               (when (>= max-age reasonable-max-age)
                 (set! max-age reasonable-max-age))
               (date-add date max-age))
              (expires expires)
              (else #f)))))
#+end_src

*** Merge the headers of two responses
The difficult part is that this will happen every time a response is
refreshed from a 304 Not Modified response. So if we just merge the
alists, the header alist will grow arithmetically.  The trick is to
use a hash table to remove duplicate header names.  This explains
function [[fn-merge-headers]].

#+name: fn-merge-headers
#+caption: Merge the headers for two responses
#+begin_src scheme :eval no
  (define (merge-headers most-important less-important)
    (let ((h+ (response-headers most-important))
          (h- (response-headers less-important)))
      (let* ((alist (append h+ h-))
             (table (alist->hash-table alist)))
        (hash-table->alist table))))
#+end_src

** Detect useless responses
If we just cache all responses for ever, the cache will grow
indefinitely and eat up all the memory. However, some responses are
useless, and we should not keep them for too long. Useless responses
are responses that have expired some time ago. Since the Date header
is always refreshed when the response is refreshed with ETags, this
means that the ETag responses have not been used for a day.

Function [[fn-useful-p]] tests whether a response is useful.

#+name: fn-useful-p
#+caption: Check whether a response is useful
#+begin_src scheme :eval no
  (define (useful? current-time response response-body)
    (let ((end (date-add
                (or (estimate-response-expires response)
                    (response-date response))
                (* 1 24 60 60))))
      (not (date>=? current-time end))))
#+end_src
** Wrap =http-fetch= so as to store the response
Now we can start using the =covoiturage= function. The inner function
will call =http-get=, but between the moment we have the response and
response body and the moment we return them, we add them to a web
cache.

The function [[fn-with-cache-add-to-box]] atomically adds the response and
response-body to the cache, while ensuring there is a Date header. It
also filters the other entries so as to remove the useless responses.

#+name: fn-with-cache-add-to-box
#+caption: Add a response and its body to the cache
#+begin_src scheme :eval no
  (define (with-cache-add-to-box box key response body current-time)
    (set! response (response-with-date response current-time))
    (let* ((old (atomic-box-ref box))
           (new (acons key (cons response body)
                       (filter
                        (lambda (cell)
                          (and (not (equal? key (car cell)))
                               (useful? current-time (cadr cell) (cddr cell))))
                        old)))
           (discarded (atomic-box-compare-and-swap! box old new)))
      (if (eq? discarded old)
          (values response body)
          (with-cache-add-to-box box key response body current-time))))
#+end_src

The function [[fn-with-cache-smart-http-get]] takes more argument than
regular =http-get=. It takes a base response and base response body,
which are the previously cached versions of the response. Then, it
proceeds with =http-get=. If the result is a 304 Not Modified
response, then it updates the cached response with the headers from
the new response and returns the previous response. Also, it updates
the cache just before returning.

#+name: fn-with-cache-smart-http-get
#+caption: =http-get=, but compare with the previous cached responses
#+begin_src scheme :eval no
  (define (with-cache-smart-http-get base-response base-response-body
                                     box current-time http-get key . args)
    (receive (response response-body)
        (apply http-get key args)
      (set! response (response-with-date response current-time))
      (when (and base-response (eq? (response-code response) 304))
        (set! response
          (build-response
           #:version (response-version base-response)
           #:code (response-code base-response)
           #:reason-phrase (response-reason-phrase base-response)
           #:headers (merge-headers response base-response)
           #:port (response-port response)))
        (set! response-body base-response-body))
      (with-cache-add-to-box box key response response-body current-time)))
#+end_src

We can now wrap =with-cache-smart-http-get= within covoiturage, and
focus on the main function. The main function takes the box,
current-time and smart http-get function within covoiturage, looks up
the response in the cache, and returns it as is if it is still
valid. Otherwise, the smart http-get is called. This is the job of
function [[fn-with-cache-query]].

#+name: fn-with-cache-query
#+caption: The main function for the cache
#+begin_src scheme :eval no
  (define (with-cache-query box current-time smart-http-get http-get key . args)
    (let* ((old (atomic-box-ref box))
           (resp (assoc-ref old key)))
      (receive (request-headers other-request-args)
          (filter-keyword #:headers args)
        (unless request-headers
          (set! request-headers '()))
        (set! request-headers (filter-headers request-headers))
        (if resp
            (let ((response (car resp))
                  (response-body (cdr resp)))
              (let ((expires (estimate-response-expires response)))
                (if (and expires (date>=? expires current-time))
                    ;; The response is cached and not expired
                    (values response response-body)
                    ;; The response is cached but expired
                    (let ((etag (response-etag response))
                          (last-modified (response-last-modified response)))
                      (when (and etag (cdr etag))
                        (set! request-headers
                          (acons 'if-none-match (list (car etag)) request-headers)))
                      (when last-modified
                        (set! request-headers
                          (acons 'if-modified-since last-modified request-headers)))
                      (apply smart-http-get
                             response response-body box current-time http-get key
                             #:headers request-headers
                             other-request-args)))))
            ;; The response is not cached
            (apply smart-http-get
                   #f #f box current-time http-get key
                   #:headers request-headers
                   other-request-args)))))
#+end_src

** Putting everything together
The final function is [[fn-with-cache]]. It takes its docstring from
[[with-cache-docstring]].

#+name: with-cache-docstring
#+caption: Docstring of the http-get with cache
#+begin_src texinfo
  Call @var{http-get} with a cache. @var{http-get} is a procedure that
  takes an URI, and optionally some headers and other arguments, and
  either fails or returns two values: a response and a response body. By
  default, it is @code{http-get} from @code{(web client)}.

  The following headers are set by the caching function and are thus
  ignored from the additional arguments:

  @itemize
  @item @code{If-None-Match}
  @item @code{If-Modified-Since}
  @end itemize

  For cache validation without connecting to the server,
  @var{current-time} is needed. It should be thunk, returning a SRFI-19
  time or date. By default, we use the client system clock.

  The returned function takes the same arguments as @var{http-get} from
  @code{(web client)}, and also returns a response and a response body.

  The optional argument @code{#:box} can be used to use a specific
  atomic box instead of one created just for the occasion. If you
  specify this argument, the contained value should be an alist, mapping
  URIs to pairs of (response, response body).
#+end_src

#+name: fn-with-cache
#+caption: HTTP GET with cache
#+begin_src scheme :eval no :noweb no-export :tangle web/client/with-cache.scm :mkdirp t
  (define-module (web client with-cache)
    #:use-module (web client)
    #:use-module (web response)
    #:use-module (srfi srfi-1)
    #:use-module (srfi srfi-19)
    #:use-module (srfi srfi-69)
    #:use-module (ice-9 atomic)
    #:use-module (ice-9 receive)
    #:use-module (ice-9 optargs)
    #:use-module (ice-9 threads)
    #:use-module (ice-9 futures))

  <<fn-covoiturage>>

  (define*-public (with-cache
                   #:key
                   (http-get http-get)
                   (current-time current-time)
                   (box #f))
    "\
  <<with-cache-docstring>>"
    (let ((box (or box (make-atomic-box '()))))
      <<fn-filter-keyword>>
      <<fn-filter-headers>>
      <<fn-date-add>>
      <<fn-date-geqp>>
      <<fn-response-with-date>>
      <<fn-estimate-response-expires>>
      <<fn-merge-headers>>
      <<fn-useful-p>>
      <<fn-with-cache-add-to-box>>
      <<fn-with-cache-smart-http-get>>
      (define smart-http-get
        (covoiturage with-cache-smart-http-get))
      <<fn-with-cache-query>>
      (lambda (uri . args)
        (apply with-cache-query box (current-time) smart-http-get http-get uri args))))
#+end_src

* Test cases
We provide a few test cases to ensure that the caching works. First,
function [[fn-run-test-case]] is defined. It takes a list of request URIs
with time, a list of expected requests with their headers, and a list
of responses. It will process each request in order, check that the
back-end receive the expected requests, and respond the responses.

#+name: fn-run-test-case
#+caption: Run a test case
#+begin_src scheme :eval no
  (define (headers-equal? x y)
    (equal? (hash-table->alist (alist->hash-table x))
            (hash-table->alist (alist->hash-table y))))

  (define (response-equal? x y)
    (and (equal? (response-version x) (response-version y))
         (eq? (response-code x) (response-code y))
         (equal? (response-reason-phrase x)
                 (response-reason-phrase y))
         (headers-equal? (response-headers x) (response-headers y))))

  (define (run-test-case)
    (define current-time (make-time time-utc 0 0))
    (define backend-request-uri #f)
    (define backend-request-headers #f)
    (define backend-response #f)
    (define backend-response-body #f)
    (define cache (make-atomic-box '()))
    (let ((http-get
           (with-cache
            #:box
            cache
            #:current-time
            (lambda () current-time)
            #:http-get
            (lambda* (uri #:key (headers '()))
              (unless (and backend-request-uri
                           backend-request-headers
                           backend-response)
                (format (current-error-port)
                        "Test failed: the backend did not expect to have to respond to ~s ~s.\n"
                        uri headers)
                (format (current-error-port) "The cache is currently ~s.\n" (atomic-box-ref cache))
                (exit 1))
              (unless (and (equal? uri backend-request-uri)
                           (headers-equal? headers backend-request-headers))
                (format (current-error-port)
                        "Test failed: the backend expected to respond to ~s ~s, not ~s ~s.\n"
                        backend-request-uri backend-request-headers
                        uri headers)
                (format (current-error-port) "The cache is currently ~s.\n" (atomic-box-ref cache))
                (exit 2))
              (let ((ret-response backend-response)
                    (ret-body backend-response-body))
                (set! backend-request-uri #f)
                (set! backend-request-headers #f)
                (set! backend-response #f)
                (set! backend-response-body #f)
                (values ret-response ret-body))))))
      (lambda (time
               uri headers
               request-uri request-headers
               response response-body
               expected-response expected-response-body)
        (set! current-time
          (make-time time-utc 0 time))
        (set! backend-request-uri request-uri)
        (set! backend-request-headers request-headers)
        (set! backend-response response)
        (set! backend-response-body response-body)
        (receive (true-response true-response-body)
            (http-get uri #:headers headers)
          (when (or backend-request-uri backend-request-headers backend-response)
            (format (current-error-port)
                    "Test failed: there should have been a backend call to ~s ~s for which we would have responded ~s ~s.\n" backend-request-uri backend-request-headers
                    backend-response backend-response-body)
            (format (current-error-port) "The cache is currently ~s.\n" (atomic-box-ref cache))
            (exit 3))
          (unless (and (response-equal? true-response expected-response)
                       (equal? true-response-body expected-response-body))
            (format (current-error-port)
                    "Test failed: the response should be ~s / ~s, but it is ~s / ~s.\n"
                    expected-response expected-response-body true-response true-response-body)
            (format (current-error-port) "The cache is currently ~s.\n" (atomic-box-ref cache))
            (exit 4))))))
#+end_src

Let's see immediately an example, program [[test-1]].

#+name: test-1
#+caption: Check that responses with a max-age cache control are cached, until the expiration date
#+begin_src scheme :eval no
  (let ((script (run-test-case)))
    ;; At time 0, we request example.com, which is cached for 10
    ;; seconds. Since the cache is empty, it translates directly to a
    ;; call to the back-end with no headers.
    (let ((response-at-0
           (build-response
            #:code 200
            #:reason-phrase "OK"
            #:headers `((date . ,(time-utc->date (make-time time-utc 0 0)))
                        (cache-control . (public (max-age . 10)))
                        (content-type . (application/json)))))
          (response-body-at-0 "{}"))
      (script 0 "https://example.com" '()
              "https://example.com" '()
              response-at-0 response-body-at-0
              response-at-0 response-body-at-0)
      ;; At time 5, we request example.com again, and we don't expect
      ;; the back-end to generate a call.
      (script 5 "https://example.com" '()
              #f #f
              #f #f
              response-at-0 response-body-at-0)
      ;; At time 15, we request example.com again, and we expect the
      ;; cache to be invalid.
      (let ((response-at-15
             (build-response
              #:code 200
              #:reason-phrase "OK"
              #:headers `((date . ,(time-utc->date (make-time time-utc 0 15)))
                          (cache-control . (public (max-age . 10)))
                          (content-type . (application/json)))))
            (response-body-at-15 "{\"updated\": \"yes!\"}"))
        (script 15 "https://example.com" '()
                "https://example.com" '()
                response-at-15 response-body-at-15
                response-at-15 response-body-at-15))))
#+end_src

#+name: test-2
#+caption: Check that responses with a max-age cache control are cached, until the expiration date
#+begin_src scheme :eval no
  (let ((script (run-test-case)))
    ;; At time 0, we request example.com, which is set to expire at time
    ;; 10. Since the cache is empty, it translates directly to a call to
    ;; the back-end with no headers.
    (let ((response-at-0
           (build-response
            #:code 200
            #:reason-phrase "OK"
            #:headers `((date . ,(time-utc->date (make-time time-utc 0 0)))
                        (expires . ,(time-utc->date (make-time time-utc 0 10)))
                        (content-type . (application/json)))))
          (response-body-at-0 "{}"))
      (script 0 "https://example.com" '()
              "https://example.com" '()
              response-at-0 response-body-at-0
              response-at-0 response-body-at-0)
      ;; At time 5, we request example.com again, and we don't expect
      ;; the back-end to generate a call.
      (script 5 "https://example.com" '()
              #f #f
              #f #f
              response-at-0 response-body-at-0)
      ;; At time 15, we request example.com again, and we expect the
      ;; cache to be invalid.
      (let ((response-at-15
             (build-response
              #:code 200
              #:reason-phrase "OK"
              #:headers `((date . ,(time-utc->date (make-time time-utc 0 15)))
                          (expires . ,(time-utc->date (make-time time-utc 0 25)))
                          (content-type . (application/json)))))
            (response-body-at-15 "{\"updated\": \"yes!\"}"))
        (script 15 "https://example.com" '()
                "https://example.com" '()
                response-at-15 response-body-at-15
                response-at-15 response-body-at-15))))
#+end_src

#+name: test-3
#+caption: Check that responses with an ETag that are used once per day stay
#+begin_src scheme :eval no
  (let ((script (run-test-case)))
    ;; At time 0, we request example.com, which sets an ETag.
    (let ((response-at-0
           (build-response
            #:code 200
            #:reason-phrase "OK"
            #:headers `((date . ,(time-utc->date (make-time time-utc 0 0)))
                        (etag . ("Version_0.0.0" . #t))
                        (content-type . (application/json)))))
          (response-body-at-0 "{}"))
      (script 0 "https://example.com" '()
              "https://example.com" '()
              response-at-0 response-body-at-0
              response-at-0 response-body-at-0))
    ;; 20 hours later, we request that again.
    (let ((response-20-hours-later
           (build-response
            #:code 304
            #:reason-phrase "Not Modified"
            #:headers `((date . ,(time-utc->date (make-time time-utc 0 (* 20 60 60))))))))
      (script (* 20 60 60) "https://example.com" '()
              "https://example.com" '((if-none-match "Version_0.0.0"))
              response-20-hours-later #f
              (build-response
               #:code 200
               #:reason-phrase "OK"
               #:headers `((date . ,(time-utc->date (make-time time-utc 0 (* 20 60 60))))
                           (etag . ("Version_0.0.0" . #t))
                           (content-type . (application/json))))
              "{}"))
    ;; 30 hours later, it should have been cleaned. We first trigger a
    ;; cleaning:
    (let ((cleaning-response
           (build-response
            #:code 200
            #:reason-phrase "OK")))
      (script (* 50 60 60) "https://example.com/cleanup" '()
              "https://example.com/cleanup" '()
              cleaning-response #f
              (build-response
               #:code 200
               #:reason-phrase "OK"
               ;; The date is automatically added:
               #:headers `((date . ,(time-utc->date (make-time time-utc 0 (* 50 60 60))))))
              #f))
    ;; Then we try the primary URL again:
    (script (* 50 60 60) "https://example.com" '()
            "https://example.com" '()
            (build-response
             #:code 200
             #:reason-phrase "OK"
             #:headers `((date . ,(time-utc->date (make-time time-utc 0 (* 50 60 60))))
                         (etag . ("Version_0.1.0" . #t))
                         (content-type . (application/json))))
            "{\"version\": \"0.1.0\"}"
            (build-response
             #:code 200
             #:reason-phrase "OK"
             #:headers `((date . ,(time-utc->date (make-time time-utc 0 (* 50 60 60))))
                         (etag . ("Version_0.1.0" . #t))
                         (content-type . (application/json))))
            "{\"version\": \"0.1.0\"}"))
#+end_src

#+name: test-4
#+caption: Check that responses with a max-age of one day and an ETag are still present 1.5 days later
#+begin_src scheme :eval no
  (let ((script (run-test-case)))
    ;; At time 0, we request example.com, which sets an ETag and has a max-age of 1 day.
    (let ((response-at-0
           (build-response
            #:code 200
            #:reason-phrase "OK"
            #:headers `((date . ,(time-utc->date (make-time time-utc 0 0)))
                        (etag . ("Version_0.0.0" . #t))
                        (cache-control . (public (max-age . ,(* 24 60 60))))
                        (content-type . (application/json)))))
          (response-body-at-0 "{}"))
      (script 0 "https://example.com" '()
              "https://example.com" '()
              response-at-0 response-body-at-0
              response-at-0 response-body-at-0))
    ;; 1.5 days later, we request that again. The response has not been
    ;; expired for a full day yet, so it should still be present.
    (let ((response-later
           (build-response
            #:code 304
            #:reason-phrase "Not Modified"
            #:headers `((date . ,(time-utc->date (make-time time-utc 0 (* 30 60 60))))))))
      (script (* 30 60 60) "https://example.com" '()
              "https://example.com" '((if-none-match "Version_0.0.0"))
              response-later #f
              (build-response
               #:code 200
               #:reason-phrase "OK"
               #:headers `((date . ,(time-utc->date (make-time time-utc 0 (* 30 60 60))))
                           (etag . ("Version_0.0.0" . #t))
                           (cache-control . (public (max-age . ,(* 24 60 60))))
                           (content-type . (application/json))))
              "{}")))
#+end_src

#+name: test-5
#+caption: Check that responses with a max-age of a week disappear after one day
#+begin_src scheme :eval no
  (let ((script (run-test-case)))
    ;; At time 0, we request example.com, which sets a max-age of 10 days.
    (let ((response-at-0
           (build-response
            #:code 200
            #:reason-phrase "OK"
            #:headers `((date . ,(time-utc->date (make-time time-utc 0 0)))
                        (cache-control . (public (max-age . ,(* 10 24 60 60))))
                        (content-type . (application/json)))))
          (response-body-at-0 "{}"))
      (script 0 "https://example.com" '()
              "https://example.com" '()
              response-at-0 response-body-at-0
              response-at-0 response-body-at-0))
    ;; After 30 hours, clean
    (let ((cleaning-response
           (build-response
            #:code 200
            #:reason-phrase "OK"
            #:headers `((date . ,(time-utc->date (make-time time-utc 0 (* 30 60 60))))))))
      (script (* 30 60 60) "https://example.com/cleanup" '()
              "https://example.com/cleanup" '()
              cleaning-response #f
              cleaning-response #f))
    ;; So now the response should not be cached anymore.
    (let ((response-next-day
           (build-response
            #:code 200
            #:reason-phrase "OK"
            #:headers `((date . ,(time-utc->date (make-time time-utc 0 (* 30 60 60))))
                        (content-type . (application/json)))))
          (response-body-next-day "{\"updated\": \"yes!\"}"))
      (script (* 30 60 60) "https://example.com" '()
              "https://example.com" '()
              response-next-day response-body-next-day
              response-next-day response-body-next-day)))
#+end_src

** All the tests                                                   :noexport:
#+name: tests
#+begin_src scheme :eval no :noweb yes :tangle tests/tests.scm :mkdirp t
  (define-module (tests-harness)
    #:use-module (web client with-cache)
    #:use-module (web response)
    #:use-module (ice-9 atomic)
    #:use-module (ice-9 receive)
    #:use-module (srfi srfi-19)
    #:use-module (srfi srfi-64)
    #:use-module (srfi srfi-69))

  (module-define! (resolve-module '(srfi srfi-64))
                  'test-log-to-file #f)

  <<fn-run-test-case>>

  (test-begin "test-1")

  <<test-1>>

  (test-end "test-1")
  (test-begin "test-2")

  <<test-2>>

  (test-end "test-2")
  (test-begin "test-3")

  <<test-3>>

  (test-end "test-3")
  (test-begin "test-4")

  <<test-4>>

  (test-end "test-4")
  (test-begin "test-5")

  <<test-5>>

  (test-end "test-5")
#+end_src
* GNU Free Documentation License
  :PROPERTIES:
  :APPENDIX: t
  :END:
#+texinfo: @include fdl.texi
* Index
  :PROPERTIES:
  :INDEX:    cp
  :END: